perf: copy contiguous hyperslab and point reads run by run

A 256 x 256 hyperslab of a contiguous f32 dataset read at an eighth of
h5py's speed: partial_read copied the bounding box out of the file, the
extractor then walked it element by element (a recursive call and two
bounds checks per element) into a second buffer, and read_f32_selection
converted that into a third.

Selections of contiguous data are now copied straight from the file, one
memcpy per run of elements contiguous in the file (gather.rs: a block
along the last dimension, touching blocks as one range, whole rows
merged), with no zero-filled intermediate and no full copy for large
selections. The typed selection readers copy into their Vec<T> directly
when the dataset stores T natively (new data_read::read_selection_native
and sealed NativeElement trait, which the read_as_* fast paths now share;
read_as_u64 gains one) and convert as before otherwise. The general
extractor used by the chunked paths runs on the same run walker, keeping
its old handling of unvalidated selections.

Checked against h5py (contiguous_read_interop.rs) for strided, blocked,
adjacent-block and whole-row hyperslabs, points and empty selections of
every 1-8-byte type in both byte orders, ranks 1-4.

Also keeps the huge-page threshold constant out of no_std builds, where
it was unused.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 08:21:47 -05:00
co-authored by Claude Opus 5.5
parent 78c769f179
commit 2bc4cb46a6
8 changed files with 586 additions and 170 deletions
+21
View File
@@ -18,6 +18,27 @@
`crates/clawhdf5/tests/contiguous_read_interop.rs` covers every 1-8-byte `crates/clawhdf5/tests/contiguous_read_interop.rs` covers every 1-8-byte
integer and float type in both byte orders, ranks 1-4, and datasets past integer and float type in both byte orders, ranks 1-4, and datasets past
the 4 MiB threshold. the 4 MiB threshold.
- **Hyperslab and point reads of contiguous data copy runs, not elements.**
A 256 x 256 hyperslab of a contiguous `f32` dataset read at an eighth of
h5py's speed: the selection's bounding box was copied out of the file,
then walked element by element (a recursive call and two bounds checks per
element) into a second buffer, which `read_f32_selection` converted into
a third. Selections of contiguous data are now copied straight from the
file, one `memcpy` per run of elements that is contiguous in the file
(a block along the last dimension, blocks that touch, and whole rows when
the inner dimensions are selected in full, merged), with no zero-filled
intermediate; a selection covering most of the dataset no longer makes a
full copy first. The typed selection readers (`read_f32_selection`,
`read_f64_selection`, `read_i32_selection`, `read_i64_selection`) copy
directly into their output when the dataset stores that type natively,
and convert as before otherwise (big-endian, other widths). The chunked
paths use the same run-based extraction. New public
`clawhdf5_format::data_read::read_selection_native` and the sealed
`NativeElement` trait (also used by the `read_as_*` fast paths, which
gained one for native `u64`). Values are unchanged: checked against h5py
by `contiguous_read_interop.rs` (strided, blocked, adjacent-block and
whole-row hyperslabs, points, empty selections; every type, both byte
orders, ranks 1-4).
### Plugin filters (2026-09-26) ### Plugin filters (2026-09-26)
- **LZF, bitshuffle, bzip2 and Blosc read and write, in pure Rust.** Files - **LZF, bitshuffle, bzip2 and Blosc read and write, in pure Rust.** Files
+1
View File
@@ -19,6 +19,7 @@
use alloc::vec::Vec; use alloc::vec::Vec;
/// Buffers smaller than this are left alone (numpy uses the same threshold). /// Buffers smaller than this are left alone (numpy uses the same threshold).
#[cfg(any(target_os = "linux", test))]
pub(crate) const HUGE_PAGE_THRESHOLD: usize = 4 << 20; pub(crate) const HUGE_PAGE_THRESHOLD: usize = 4 << 20;
/// Advise the kernel to back `[ptr, ptr + len)` with transparent huge pages, /// Advise the kernel to back `[ptr, ptr + len)` with transparent huge pages,
+151 -128
View File
@@ -531,6 +531,11 @@ pub fn extract_selection_from_buffer(
block, block,
} => { } => {
let rank = dims.len(); let rank = dims.len();
if [start.len(), stride.len(), count.len(), block.len()] != [rank; 4] {
return Err(FormatError::SelectionOutOfBounds(format!(
"hyperslab rank does not match dataset rank {rank}"
)));
}
let output_elements = count let output_elements = count
.iter() .iter()
.zip(block.iter()) .zip(block.iter())
@@ -540,96 +545,40 @@ pub fn extract_selection_from_buffer(
crate::chunked_read::checked_byte_len(output_elements, elem_size)?, crate::chunked_read::checked_byte_len(output_elements, elem_size)?,
)?; )?;
// Compute dataset strides (row-major) // One copy per run of elements contiguous in `full_data`
let mut ds_strides = vec![1usize; rank]; // (`gather`'s runs). Coordinates past the extent are skipped and
for i in (0..rank.saturating_sub(1)).rev() { // runs past the end of `full_data` left as zeros, element by
ds_strides[i] = ds_strides[i + 1] * dims[i + 1] as usize; // element, as this extractor always did; validated selections
} // never hit either.
let mut out_at = 0usize;
// Compute output shape and strides crate::gather::hyperslab_runs(dims, start, stride, count, block, |first, n| {
let output_dims: Vec<usize> = count let big = |v: u64| usize::try_from(v).unwrap_or(usize::MAX);
.iter() let (first, n) = (big(first), big(n));
.zip(block.iter()) let len = n.saturating_mul(elem_size);
.map(|(&c, &b)| (c * b) as usize) let src = first.saturating_mul(elem_size);
.collect(); let out_end = out_at.saturating_add(len);
let mut out_strides = vec![1usize; rank]; if let (Some(from), Some(to)) = (
for i in (0..rank.saturating_sub(1)).rev() { full_data.get(src..src.saturating_add(len)),
out_strides[i] = out_strides[i + 1] * output_dims[i + 1]; output.get_mut(out_at..out_end),
} ) {
to.copy_from_slice(from);
// Iterate over all selected elements } else {
// For each block in the hyperslab, copy the elements for k in 0..n {
let mut out_linear = 0usize; let s = first.saturating_add(k).saturating_mul(elem_size);
let _block_coords = vec![0u64; rank]; let o = out_at.saturating_add(k.saturating_mul(elem_size));
if o >= output.len() {
#[allow(clippy::too_many_arguments)] break;
fn iterate_hyperslab( }
d: usize, if let (Some(from), Some(to)) = (
rank: usize, full_data.get(s..s.saturating_add(elem_size)),
start: &[u64], output.get_mut(o..o.saturating_add(elem_size)),
stride: &[u64], ) {
count: &[u64], to.copy_from_slice(from);
block: &[u64],
dims: &[u64],
ds_strides: &[usize],
elem_size: usize,
full_data: &[u8],
output: &mut [u8],
out_linear: &mut usize,
current_ds_offset: usize,
) {
if d == rank {
// Copy one element
let src = current_ds_offset * elem_size;
let dst = *out_linear * elem_size;
if src + elem_size <= full_data.len() && dst + elem_size <= output.len() {
output[dst..dst + elem_size]
.copy_from_slice(&full_data[src..src + elem_size]);
}
*out_linear += 1;
return;
}
for bi in 0..count[d] {
let block_start = start[d] + bi * stride[d];
for bj in 0..block[d] {
let coord = block_start + bj;
if coord < dims[d] {
iterate_hyperslab(
d + 1,
rank,
start,
stride,
count,
block,
dims,
ds_strides,
elem_size,
full_data,
output,
out_linear,
current_ds_offset + coord as usize * ds_strides[d],
);
} }
} }
} }
} out_at = out_end;
});
iterate_hyperslab(
0,
rank,
start,
stride,
count,
block,
dims,
&ds_strides,
elem_size,
full_data,
&mut output,
&mut out_linear,
0,
);
Ok(output) Ok(output)
} }
@@ -757,22 +706,76 @@ fn get_size(dt: &Datatype) -> usize {
dt.type_size() as usize dt.type_size() as usize
} }
/// Reinterpret little-endian bytes as `count` native values of `T` on a mod sealed {
/// little-endian target, in one copy. pub trait Sealed {}
}
/// A numeric type whose values can be copied straight out of a dataset's
/// bytes when the dataset stores exactly that type in the target's byte
/// order: `u8`, `i32`, `i64`, `u64`, `f32` and `f64`.
///
/// # Safety
///
/// Implementors have no padding and no invalid bit patterns, so a buffer of
/// them may be filled by copying bytes. The trait is sealed.
pub unsafe trait NativeElement: sealed::Sealed + Copy + 'static {
/// Whether `datatype`'s stored bytes are this type's native in-memory
/// representation (same size, byte order, signedness, full precision,
/// IEEE layout), so reading needs a copy and no conversion.
fn is_native(datatype: &Datatype) -> bool;
}
/// A full-width fixed-point type of `size` bytes and the given signedness in
/// the target's byte order.
fn is_native_int(datatype: &Datatype, size: u32, want_signed: bool) -> bool {
let order = if cfg!(target_endian = "little") {
DatatypeByteOrder::LittleEndian
} else {
DatatypeByteOrder::BigEndian
};
matches!(
datatype,
Datatype::FixedPoint { size: s, signed, byte_order, .. }
if *s == size && *signed == want_signed && (size == 1 || *byte_order == order)
) && is_full_width(datatype)
}
macro_rules! native_element {
($($t:ty => |$dt:ident| $check:expr;)*) => {$(
impl sealed::Sealed for $t {}
// SAFETY: a primitive integer or float: no padding, and every bit
// pattern is a valid value.
unsafe impl NativeElement for $t {
fn is_native($dt: &Datatype) -> bool {
$check
}
}
)*};
}
native_element! {
u8 => |dt| is_native_int(dt, 1, false);
i32 => |dt| is_native_int(dt, 4, true);
i64 => |dt| is_native_int(dt, 8, true);
u64 => |dt| is_native_int(dt, 8, false);
f32 => |dt| cfg!(target_endian = "little") && is_native_le_float(dt, FloatFormat::Single);
f64 => |dt| cfg!(target_endian = "little") && is_native_le_float(dt, FloatFormat::Double);
}
/// Copy `count` values of `T` out of `raw`, which holds them in `T`'s native
/// representation (see [`NativeElement::is_native`]), in one copy.
/// ///
/// The buffer is allocated uninitialised and filled by the copy. It used to be /// The buffer is allocated uninitialised and filled by the copy. It used to be
/// `vec![0; count]` first, which for a large dataset meant writing every page /// `vec![0; count]` first, which for a large dataset meant writing every page
/// twice (zero it, then overwrite it) — about as expensive as the copy itself. /// twice (zero it, then overwrite it) — about as expensive as the copy itself.
#[cfg(target_endian = "little")] fn native_to_vec<T: NativeElement>(raw: &[u8], count: usize) -> Vec<T> {
fn native_le_to_vec<T: Copy>(raw: &[u8], count: usize) -> Vec<T> {
let bytes = count * core::mem::size_of::<T>(); let bytes = count * core::mem::size_of::<T>();
debug_assert!(bytes <= raw.len()); assert!(bytes <= raw.len(), "native_to_vec: source too short");
let mut result: Vec<T> = crate::bulk_alloc::vec_for_bulk(count); let mut result: Vec<T> = crate::bulk_alloc::vec_for_bulk(count);
// SAFETY: `result` has capacity for `count` values of `T`, i.e. `bytes` // SAFETY: `result` has capacity for `count` values of `T`, i.e. `bytes`
// bytes; `raw` holds at least `bytes` bytes (callers derive `count` from // bytes; `raw` holds at least `bytes` bytes (asserted); the regions
// `raw.len() / size_of::<T>()`); the regions cannot overlap because // cannot overlap because `result` was just allocated. `T: NativeElement`
// `result` was just allocated. Every `T` used here (f32/f64/i32/i64) is // is valid for any bit pattern, so after the copy all `count` values are
// valid for any bit pattern, so after the copy all `count` values are
// initialised and `set_len` is sound. // initialised and `set_len` is sound.
unsafe { unsafe {
core::ptr::copy_nonoverlapping(raw.as_ptr(), result.as_mut_ptr().cast::<u8>(), bytes); core::ptr::copy_nonoverlapping(raw.as_ptr(), result.as_mut_ptr().cast::<u8>(), bytes);
@@ -781,6 +784,44 @@ fn native_le_to_vec<T: Copy>(raw: &[u8], count: usize) -> Vec<T> {
result result
} }
/// Read `selection` of a dataset whose raw bytes (all of them, row-major, of
/// shape `dims`) are `raw` — typically a contiguous dataset's bytes borrowed
/// from the file — straight into a `Vec<T>`, copying each contiguous run of
/// selected elements once.
///
/// Returns `Ok(None)` when `datatype` is not `T`'s native representation
/// ([`NativeElement::is_native`]); the caller then converts through
/// [`read_raw_data_selection`] and the `read_as_*` functions. The selection is
/// validated like every selection read: out-of-range coordinates are
/// [`FormatError::SelectionOutOfBounds`].
pub fn read_selection_native<T: NativeElement>(
raw: &[u8],
dims: &[u64],
datatype: &Datatype,
selection: &crate::selection::Selection,
) -> Result<Option<Vec<T>>, FormatError> {
if !T::is_native(datatype) {
return Ok(None);
}
let elem_size = core::mem::size_of::<T>();
let total = dims
.iter()
.try_fold(1u64, |acc, &d| acc.checked_mul(d))
.ok_or_else(|| FormatError::Overflow("dataset shape overflows".into()))?;
let expected = crate::chunked_read::checked_byte_len(total, elem_size)?;
if raw.len() != expected {
return Err(FormatError::DataSizeMismatch {
expected,
actual: raw.len(),
});
}
if let crate::selection::Selection::All = selection {
return Ok(Some(native_to_vec(raw, expected / elem_size)));
}
crate::partial_read::validate(selection, dims)?;
crate::gather::gather::<T>(raw, dims, elem_size, selection).map(Some)
}
/// Convert raw bytes to `f64` values. /// Convert raw bytes to `f64` values.
pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatError> { pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatError> {
// Array datatypes read as a flat sequence of their base elements, and // Array datatypes read as a flat sequence of their base elements, and
@@ -799,9 +840,8 @@ pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatEr
let count = raw.len() / elem_size; let count = raw.len() / elem_size;
// Fast path: native-endian f64 — single bulk memcpy // Fast path: native-endian f64 — single bulk memcpy
#[cfg(target_endian = "little")] if f64::is_native(datatype) {
if is_native_le_float(datatype, FloatFormat::Double) { return Ok(native_to_vec::<f64>(raw, count));
return Ok(native_le_to_vec::<f64>(raw, count));
} }
let order = get_byte_order(datatype); let order = get_byte_order(datatype);
@@ -941,19 +981,8 @@ pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatEr
let count = raw.len() / elem_size; let count = raw.len() / elem_size;
// Fast path: native LE i64 — single bulk memcpy // Fast path: native LE i64 — single bulk memcpy
#[cfg(target_endian = "little")] if i64::is_native(datatype) {
if elem_size == 8 return Ok(native_to_vec::<i64>(raw, count));
&& is_full_width(datatype)
&& matches!(
datatype,
Datatype::FixedPoint {
byte_order: DatatypeByteOrder::LittleEndian,
signed: true,
..
}
)
{
return Ok(native_le_to_vec::<i64>(raw, count));
} }
let order = get_byte_order(datatype); let order = get_byte_order(datatype);
@@ -986,6 +1015,12 @@ pub fn read_as_u64(raw: &[u8], datatype: &Datatype) -> Result<Vec<u64>, FormatEr
}); });
} }
let count = raw.len() / elem_size; let count = raw.len() / elem_size;
// Fast path: native u64 — single bulk memcpy
if u64::is_native(datatype) {
return Ok(native_to_vec::<u64>(raw, count));
}
let order = get_byte_order(datatype); let order = get_byte_order(datatype);
let mut result = crate::bulk_alloc::vec_for_bulk(count); let mut result = crate::bulk_alloc::vec_for_bulk(count);
for i in 0..count { for i in 0..count {
@@ -1013,9 +1048,8 @@ pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatEr
let count = raw.len() / elem_size; let count = raw.len() / elem_size;
// Fast path: native-endian f32 — single bulk memcpy // Fast path: native-endian f32 — single bulk memcpy
#[cfg(target_endian = "little")] if f32::is_native(datatype) {
if is_native_le_float(datatype, FloatFormat::Single) { return Ok(native_to_vec::<f32>(raw, count));
return Ok(native_le_to_vec::<f32>(raw, count));
} }
// Little-endian IEEE half precision (numpy float16): widen directly. // Little-endian IEEE half precision (numpy float16): widen directly.
if is_native_le_float(datatype, FloatFormat::Half) { if is_native_le_float(datatype, FloatFormat::Half) {
@@ -1103,19 +1137,8 @@ pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatEr
let count = raw.len() / elem_size; let count = raw.len() / elem_size;
// Fast path: native LE i32 — single bulk memcpy // Fast path: native LE i32 — single bulk memcpy
#[cfg(target_endian = "little")] if i32::is_native(datatype) {
if elem_size == 4 return Ok(native_to_vec::<i32>(raw, count));
&& is_full_width(datatype)
&& matches!(
datatype,
Datatype::FixedPoint {
byte_order: DatatypeByteOrder::LittleEndian,
signed: true,
..
}
)
{
return Ok(native_le_to_vec::<i32>(raw, count));
} }
let order = get_byte_order(datatype); let order = get_byte_order(datatype);
+343
View File
@@ -0,0 +1,343 @@
//! Copying a selection out of a row-major buffer one contiguous run at a time.
//!
//! A selection's elements, in output order, fall into runs that are adjacent
//! in the source: a whole block along the last dimension, blocks that touch
//! (`stride == block`), and whole rows when the inner dimensions are selected
//! in full. Copying run by run turns a 256 x 256 hyperslab of a 1024-wide
//! dataset into 256 `memcpy`s of 1 KiB, where the old extractor recursed and
//! bounds-checked once per element.
#[cfg(not(feature = "std"))]
use alloc::{vec, vec::Vec};
use crate::data_read::NativeElement;
use crate::error::FormatError;
use crate::selection::Selection;
/// Row-major element strides of `dims` (the last dimension has stride 1).
fn strides(dims: &[u64]) -> Vec<u64> {
let mut s = vec![1u64; dims.len()];
for d in (0..dims.len().saturating_sub(1)).rev() {
s[d] = s[d + 1].wrapping_mul(dims[d + 1]);
}
s
}
/// Merges adjacent runs before handing them on.
struct Coalesce<F: FnMut(u64, u64)> {
start: u64,
len: u64,
emit: F,
}
impl<F: FnMut(u64, u64)> Coalesce<F> {
#[inline]
fn push(&mut self, start: u64, len: u64) {
if len == 0 {
return;
}
if self.len > 0 && self.start.wrapping_add(self.len) == start {
self.len += len;
return;
}
self.flush();
self.start = start;
self.len = len;
}
fn flush(&mut self) {
if self.len > 0 {
(self.emit)(self.start, self.len);
self.len = 0;
}
}
}
/// Call `emit(first_element, element_count)` for each run of a hyperslab's
/// elements that is contiguous in a row-major dataset of shape `dims`, in
/// the order the selection returns them. Adjacent runs are merged.
///
/// Coordinates at or past a dimension's extent are skipped, as the
/// element-wise extractor always did; callers that want them to be an error
/// validate the selection first. The four vectors must have `dims.len()`
/// entries.
pub(crate) fn hyperslab_runs(
dims: &[u64],
start: &[u64],
stride: &[u64],
count: &[u64],
block: &[u64],
emit: impl FnMut(u64, u64),
) {
let rank = dims.len();
let mut out = Coalesce {
start: 0,
len: 0,
emit,
};
if rank == 0 {
out.push(0, 1);
out.flush();
return;
}
if (0..rank).any(|d| count[d] == 0 || block[d] == 0) {
return;
}
let strides = strides(dims);
let last = rank - 1;
// Odometer over the outer dimensions: (block index, offset in block).
let mut ci = vec![0u64; last];
let mut bi = vec![0u64; last];
'outer: loop {
// Base offset of this row, or skip it if a coordinate is out of range.
let mut base = 0u64;
let mut in_range = true;
for d in 0..last {
let coord = start[d]
.saturating_add(ci[d].saturating_mul(stride[d]))
.saturating_add(bi[d]);
if coord >= dims[d] {
in_range = false;
break;
}
base = base.wrapping_add(coord.wrapping_mul(strides[d]));
}
if in_range && (stride[last] == block[last] || count[last] == 1) {
// Blocks that touch (the common unit-stride case: block 1,
// stride 1) are one range; don't split it into per-element runs.
let s = start[last];
let e = s
.saturating_add(count[last].saturating_mul(block[last]))
.min(dims[last]);
if s < e {
out.push(base.wrapping_add(s), e - s);
}
} else if in_range {
for c in 0..count[last] {
let s = start[last].saturating_add(c.saturating_mul(stride[last]));
if s >= dims[last] {
continue;
}
let e = s.saturating_add(block[last]).min(dims[last]);
out.push(base.wrapping_add(s), e - s);
}
}
// Advance the odometer, last outer dimension fastest.
let mut d = last;
loop {
if d == 0 {
break 'outer;
}
d -= 1;
bi[d] += 1;
if bi[d] < block[d] {
break;
}
bi[d] = 0;
ci[d] += 1;
if ci[d] < count[d] {
break;
}
ci[d] = 0;
}
}
out.flush();
}
/// The selected elements of `src` — a row-major dataset of shape `dims` and
/// `elem_size`-byte elements — copied into a fresh `Vec<T>`, one `memcpy` per
/// contiguous run, with no zero-filling of the output first.
///
/// For `T` other than `u8`, `elem_size` must equal `size_of::<T>()`. The
/// selection must be a validated hyperslab, point list or `None` (`All` is the
/// caller's to handle); `src` must hold exactly the dataset. Anything that
/// would read outside `src` is an error, never a partial result.
pub(crate) fn gather<T: NativeElement>(
src: &[u8],
dims: &[u64],
elem_size: usize,
selection: &Selection,
) -> Result<Vec<T>, FormatError> {
let t_size = core::mem::size_of::<T>();
if elem_size == 0 || (t_size != 1 && t_size != elem_size) {
return Err(FormatError::DataSizeMismatch {
expected: t_size,
actual: elem_size,
});
}
let n_elements = match selection {
Selection::None => 0,
Selection::Hyperslab { count, block, .. } => count
.iter()
.zip(block)
.try_fold(1u64, |acc, (&c, &b)| acc.checked_mul(c.checked_mul(b)?))
.ok_or_else(|| FormatError::Overflow("hyperslab count x block overflows".into()))?,
Selection::Points(points) => points.len() as u64,
Selection::All => {
return Err(FormatError::SelectionOutOfBounds(
"gather does not take Selection::All".into(),
));
}
};
let out_bytes = crate::chunked_read::checked_byte_len(n_elements, elem_size)?;
let out_len = out_bytes / t_size;
let mut out: Vec<T> = crate::bulk_alloc::vec_for_bulk(out_len);
let dst = out.as_mut_ptr().cast::<u8>();
let mut written = 0usize;
let mut failed = false;
let mut copy_run = |first: u64, n: u64| {
if failed {
return;
}
let range = usize::try_from(first)
.ok()
.and_then(|f| f.checked_mul(elem_size))
.zip(
usize::try_from(n)
.ok()
.and_then(|n| n.checked_mul(elem_size)),
)
.and_then(|(at, len)| Some((at, len, at.checked_add(len)?)));
match range {
Some((at, len, end)) if end <= src.len() && written + len <= out_bytes => {
// SAFETY: `src[at..end]` is in bounds (checked above), and
// `dst + written .. + len` lies within `out`'s capacity of
// `out_bytes` bytes (checked above); `out` is a fresh
// allocation, so the regions do not overlap.
unsafe {
core::ptr::copy_nonoverlapping(src.as_ptr().add(at), dst.add(written), len)
};
written += len;
}
_ => failed = true,
}
};
let mut bad_point = false;
match selection {
Selection::Hyperslab {
start,
stride,
count,
block,
} => {
let rank = dims.len();
if [start.len(), stride.len(), count.len(), block.len()] != [rank; 4] {
return Err(FormatError::SelectionOutOfBounds(
"hyperslab rank does not match dataset rank".into(),
));
}
hyperslab_runs(dims, start, stride, count, block, &mut copy_run);
}
Selection::Points(points) => {
let strides = strides(dims);
let mut runs = Coalesce {
start: 0,
len: 0,
emit: &mut copy_run,
};
for p in points {
if p.len() != dims.len() || p.iter().zip(dims).any(|(c, n)| c >= n) {
bad_point = true;
break;
}
let at = p
.iter()
.zip(&strides)
.fold(0u64, |acc, (c, s)| acc.wrapping_add(c.wrapping_mul(*s)));
runs.push(at, 1);
}
runs.flush();
}
Selection::None | Selection::All => {}
}
if failed || bad_point || written != out_bytes {
return Err(FormatError::SelectionOutOfBounds(
"selection addresses elements outside the dataset".into(),
));
}
// SAFETY: all `out_bytes` bytes, i.e. `out_len` values of `T`, were
// written above, and every bit pattern is a valid `T` (`NativeElement`).
unsafe { out.set_len(out_len) };
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn runs(dims: &[u64], sel: [&[u64]; 4]) -> Vec<(u64, u64)> {
let mut v = Vec::new();
hyperslab_runs(dims, sel[0], sel[1], sel[2], sel[3], |s, n| v.push((s, n)));
v
}
#[test]
fn runs_merge_blocks_and_whole_rows() {
// A box: one run per row.
assert_eq!(
runs(&[4, 10], [&[1, 2], &[1, 1], &[2, 3], &[1, 1]]),
vec![(12, 3), (22, 3)]
);
// Whole rows: one run.
assert_eq!(
runs(&[4, 10], [&[1, 0], &[1, 1], &[3, 10], &[1, 1]]),
vec![(10, 30)]
);
// stride == block: blocks merge.
assert_eq!(
runs(&[1, 10], [&[0, 1], &[1, 2], &[1, 4], &[1, 2]]),
vec![(1, 8)]
);
// Strided with blocks along both dimensions.
assert_eq!(
runs(&[6, 10], [&[0, 1], &[3, 4], &[2, 2], &[2, 2]]),
vec![
(1, 2),
(5, 2),
(11, 2),
(15, 2),
(31, 2),
(35, 2),
(41, 2),
(45, 2)
]
);
// Empty.
assert!(runs(&[4, 10], [&[0, 0], &[1, 1], &[0, 3], &[1, 1]]).is_empty());
// Scalar.
assert_eq!(runs(&[], [&[], &[], &[], &[]]), vec![(0, 1)]);
}
#[test]
fn gather_matches_element_order_and_rejects_out_of_range() {
let dims = [3u64, 4];
let src: Vec<u8> = (0..12u16).flat_map(|v| v.to_le_bytes()).collect();
let sel = Selection::Hyperslab {
start: vec![0, 1],
stride: vec![2, 2],
count: vec![2, 2],
block: vec![1, 1],
};
let got: Vec<u8> = gather(&src, &dims, 2, &sel).unwrap();
let want: Vec<u8> = [1u16, 3, 9, 11]
.iter()
.flat_map(|v| v.to_le_bytes())
.collect();
assert_eq!(got, want);
let pts = Selection::Points(vec![vec![2, 3], vec![0, 0], vec![0, 1]]);
let got: Vec<u8> = gather(&src, &dims, 2, &pts).unwrap();
let want: Vec<u8> = [11u16, 0, 1].iter().flat_map(|v| v.to_le_bytes()).collect();
assert_eq!(got, want);
// Past the extent, or a source shorter than the dataset: an error.
let bad = Selection::Points(vec![vec![3, 0]]);
assert!(gather::<u8>(&src, &dims, 2, &bad).is_err());
let past = Selection::Hyperslab {
start: vec![2, 0],
stride: vec![1, 1],
count: vec![2, 4],
block: vec![1, 1],
};
assert!(gather::<u8>(&src, &dims, 2, &past).is_err());
assert!(gather::<u8>(&src[..20], &dims, 2, &pts).is_err());
}
}
+1
View File
@@ -94,6 +94,7 @@ mod filters_szip;
pub mod fixed_array; pub mod fixed_array;
pub mod float16; pub mod float16;
pub mod fractal_heap; pub mod fractal_heap;
mod gather;
pub mod global_heap; pub mod global_heap;
pub mod group_info; pub mod group_info;
pub mod group_v1; pub mod group_v1;
+31 -30
View File
@@ -3,11 +3,13 @@
//! //!
//! [`crate::data_read::read_raw_data_selection`] used to decode the *entire* //! [`crate::data_read::read_raw_data_selection`] used to decode the *entire*
//! dataset and then pick elements out of it, so reading a 64x64 window of a //! dataset and then pick elements out of it, so reading a 64x64 window of a
//! large dataset took about as long as reading all of it. Here the selection's //! large dataset took about as long as reading all of it. A contiguous
//! bounding box is materialised instead — only the rows of a contiguous //! dataset's selection is now copied straight out of the file, one `memcpy`
//! dataset, or only the chunks, that overlap it — and the existing extractor //! per contiguous run of selected elements (`crate::gather`). For chunked
//! runs over that small buffer with the selection translated to the box's //! data the selection's bounding box is materialised — only the chunks that
//! origin. Extraction semantics are therefore exactly the full-read ones. //! overlap it — and the extractor runs over that small buffer with the
//! selection translated to the box's origin. Extraction semantics are
//! therefore exactly the full-read ones.
#[cfg(not(feature = "std"))] #[cfg(not(feature = "std"))]
use alloc::string as alloc_or_std; use alloc::string as alloc_or_std;
@@ -250,10 +252,33 @@ pub fn read_selection(
if dims.is_empty() || elem_size == 0 { if dims.is_empty() || elem_size == 0 {
return Ok(None); return Ok(None);
} }
let total = dataspace.checked_num_elements()?;
// Contiguous data is addressable in place: copy the selection's runs
// straight out of it, whatever fraction of the dataset it covers, with no
// intermediate box (and no full copy for a large selection).
if let (
DataLayout::Contiguous {
address: Some(address),
..
},
Selection::Hyperslab { .. } | Selection::Points(_),
) = (layout, selection)
{
validate(selection, dims)?;
let base = usize::try_from(*address)
.map_err(|_| FormatError::Overflow("data address exceeds usize".into()))?;
let data = file_data
.get(base..)
.and_then(|d| d.get(..checked_byte_len(total, elem_size).ok()?))
.ok_or(FormatError::UnexpectedEof {
expected: base,
available: file_data.len(),
})?;
return crate::gather::gather::<u8>(data, dims, elem_size, selection).map(Some);
}
let Some((box_start, box_extent)) = bounding_box(selection, dims) else { let Some((box_start, box_extent)) = bounding_box(selection, dims) else {
return Ok(None); return Ok(None);
}; };
let total = dataspace.checked_num_elements()?;
let box_elements = box_extent let box_elements = box_extent
.iter() .iter()
.try_fold(1u64, |acc, &e| acc.checked_mul(e)) .try_fold(1u64, |acc, &e| acc.checked_mul(e))
@@ -265,30 +290,6 @@ pub fn read_selection(
let mut boxed = alloc_output(checked_byte_len(box_elements, elem_size)?)?; let mut boxed = alloc_output(checked_byte_len(box_elements, elem_size)?)?;
match layout { match layout {
DataLayout::Contiguous {
address: Some(address),
..
} => {
let base = usize::try_from(*address)
.map_err(|_| FormatError::Overflow("data address exceeds usize".into()))?;
let data = file_data
.get(base..)
.and_then(|d| d.get(..checked_byte_len(total, elem_size).ok()?))
.ok_or(FormatError::UnexpectedEof {
expected: base,
available: file_data.len(),
})?;
let origin = vec![0u64; dims.len()];
copy_overlap(
data,
&origin,
dims,
&mut boxed,
&box_start,
&box_extent,
elem_size,
);
}
DataLayout::Chunked { DataLayout::Chunked {
btree_address: Some(_), btree_address: Some(_),
.. ..
+30 -11
View File
@@ -569,9 +569,7 @@ impl<'f> Dataset<'f> {
&self, &self,
selection: &clawhdf5_format::selection::Selection, selection: &clawhdf5_format::selection::Selection,
) -> Result<Vec<f64>, Error> { ) -> Result<Vec<f64>, Error> {
let raw = self.read_selection(selection)?; self.read_typed_selection(selection, data_read::read_as_f64, || self.read_f64())
let dt = self.datatype()?;
Ok(data_read::read_as_f64(&raw, &dt)?)
} }
/// Read selected elements as `f32` values. /// Read selected elements as `f32` values.
@@ -579,9 +577,7 @@ impl<'f> Dataset<'f> {
&self, &self,
selection: &clawhdf5_format::selection::Selection, selection: &clawhdf5_format::selection::Selection,
) -> Result<Vec<f32>, Error> { ) -> Result<Vec<f32>, Error> {
let raw = self.read_selection(selection)?; self.read_typed_selection(selection, data_read::read_as_f32, || self.read_f32())
let dt = self.datatype()?;
Ok(data_read::read_as_f32(&raw, &dt)?)
} }
/// Read selected elements as `i32` values. /// Read selected elements as `i32` values.
@@ -589,9 +585,7 @@ impl<'f> Dataset<'f> {
&self, &self,
selection: &clawhdf5_format::selection::Selection, selection: &clawhdf5_format::selection::Selection,
) -> Result<Vec<i32>, Error> { ) -> Result<Vec<i32>, Error> {
let raw = self.read_selection(selection)?; self.read_typed_selection(selection, data_read::read_as_i32, || self.read_i32())
let dt = self.datatype()?;
Ok(data_read::read_as_i32(&raw, &dt)?)
} }
/// Read selected elements as `i64` values. /// Read selected elements as `i64` values.
@@ -599,9 +593,34 @@ impl<'f> Dataset<'f> {
&self, &self,
selection: &clawhdf5_format::selection::Selection, selection: &clawhdf5_format::selection::Selection,
) -> Result<Vec<i64>, Error> { ) -> Result<Vec<i64>, Error> {
let raw = self.read_selection(selection)?; self.read_typed_selection(selection, data_read::read_as_i64, || self.read_i64())
}
/// The typed selection readers. `All` is a full read. A contiguous dataset
/// that stores `T` natively is copied from the file straight into the
/// `Vec<T>`, one copy per contiguous run of selected elements; anything
/// else reads the selection's bytes and converts them with `convert`.
fn read_typed_selection<T: data_read::NativeElement>(
&self,
selection: &clawhdf5_format::selection::Selection,
convert: fn(&[u8], &Datatype) -> Result<Vec<T>, FormatError>,
full: impl FnOnce() -> Result<Vec<T>, Error>,
) -> Result<Vec<T>, Error> {
if matches!(selection, clawhdf5_format::selection::Selection::All) {
return full();
}
let dt = self.datatype()?; let dt = self.datatype()?;
Ok(data_read::read_as_i64(&raw, &dt)?) if T::is_native(&dt)
&& let Ok(Some(raw)) = self.read_raw_ref()
{
let dims = self.dataspace()?.dimensions;
if let Some(values) = data_read::read_selection_native::<T>(raw, &dims, &dt, selection)?
{
return Ok(values);
}
}
let raw = self.read_selection(selection)?;
Ok(convert(&raw, &dt)?)
} }
/// Zero-copy read of contiguous raw data. /// Zero-copy read of contiguous raw data.
+8 -1
View File
@@ -9,7 +9,8 @@ deleting it.
## Concurrent and contiguous read performance (measured 2026-09-26) ## Concurrent and contiguous read performance (measured 2026-09-26)
**Status:** open. Measured on tank with `concurrent_read` against h5py **Status:** open for chunked full reads; the contiguous item is fixed
(2026-09-26). Measured on tank with `concurrent_read` against h5py
3.16 / HDF5 2.0 (`BENCHMARKS.md`, "Concurrent reads"): 3.16 / HDF5 2.0 (`BENCHMARKS.md`, "Concurrent reads"):
- Full reads of chunked datasets from several threads through one `File` - Full reads of chunked datasets from several threads through one `File`
stop scaling at about 4 threads (880 MB/s on deflate data vs 4424 MB/s stop scaling at about 4 threads (880 MB/s on deflate data vs 4424 MB/s
@@ -17,6 +18,12 @@ deleting it.
scale to 1244 MB/s, so the `File`'s shared chunk cache is the suspect. scale to 1244 MB/s, so the `File`'s shared chunk cache is the suspect.
- Contiguous datasets read 4x slower than h5py on one thread (2.5 vs - Contiguous datasets read 4x slower than h5py on one thread (2.5 vs
9.8 GB/s full, 0.12x for 256 x 256 hyperslabs). 9.8 GB/s full, 0.12x for 256 x 256 hyperslabs).
**Fixed 2026-09-26** (not yet re-measured for `BENCHMARKS.md`): full
reads were dominated by 4 KiB page faults on the fresh output buffer,
which is now backed by transparent huge pages as numpy's is; hyperslab
reads copied the selection three times, element by element, and now copy
each contiguous run once, straight from the file into the output (see
`CHANGELOG.md`). The chunked-read scaling item above is still open.
Values are correct; this is speed only. Values are correct; this is speed only.
## Silent wrong data found by the 2026-09-25 HDF5 audit ## Silent wrong data found by the 2026-09-25 HDF5 audit