perf(py): read an index list one group of chunks at a time

Each run of consecutive indices was its own uncached hyperslab read, so
a list over a compressed chunked dataset decoded the same chunk once per
run (d[range(0, 200000, 40)] over 20 gzip chunks: 8 s, h5py 0.014 s).
Plan::reads now groups the indices — a group ends only where a whole
chunk holds no selected index, or, unchunked, at a gap over 64 KiB — and
the selected rows are gathered from each group's block in Rust. Now
3.8 ms (h5py 4.1 ms, release, tank). The new test (1-D, 2-D and
contiguous, compared with h5py, 2 s bound) took 5.8 s before.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 08:57:16 -05:00
co-authored by Claude Opus 5.5
parent 8c51b05b9c
commit b43bd2e67f
5 changed files with 259 additions and 36 deletions
+13 -2
View File
@@ -26,8 +26,8 @@
read the whole dataset and slice it in numpy, and knew six dtypes. Now read the whole dataset and slice it in numpy, and knew six dtypes. Now
integers (negative from the end), slices with positive steps, `...`, one integers (negative from the end), slices with positive steps, `...`, one
increasing list of integers per key and compound field names map onto the increasing list of integers per key and compound field names map onto the
facade's hyperslab selection (a list becomes one hyperslab per run of facade's hyperslab selection (a list is read one group of neighbouring
consecutive indices), with h5py's results (numpy scalar for an all-integer chunks at a time and picked from in memory), with h5py's results (numpy scalar for an all-integer
key, 0-d array for `scalar[...]`) and h5py's errors for everything else key, 0-d array for `scalar[...]`) and h5py's errors for everything else
(negative steps, `None`, boolean masks, out-of-range indices). (negative steps, `None`, boolean masks, out-of-range indices).
`Dataset.dtype` is the numpy dtype h5py reports, for every integer and `Dataset.dtype` is the numpy dtype h5py reports, for every integer and
@@ -69,6 +69,17 @@
from the file (h5py's, zero for files it wrote) and stays zero-copy. from the file (h5py's, zero for files it wrote) and stays zero-copy.
The h5py comparisons now also compare every byte of structured values The h5py comparisons now also compare every byte of structured values
(`test_compound_padding_bytes_match_h5py` and `assert_same`). (`test_compound_padding_bytes_match_h5py` and `assert_same`).
- **Index lists no longer decode the same chunks once per run.** A list
index was one uncached hyperslab read per run of consecutive indices, so
on a chunked, compressed dataset every run decoded its chunk again:
`d[list(range(0, 200000, 40))]` over 20 gzip chunks took 8 s (h5py:
0.014 s). The list is now read in groups — for a chunked dataset a group
ends only where a whole chunk holds no selected index, so each chunk is
decoded once; otherwise at a gap of more than 64 KiB — and the selected
rows are picked from each group in Rust. The same read now takes 3.8 ms
(h5py 4.1 ms; release build on tank, best of 5).
`test_a_long_index_list_decodes_each_chunk_once` compares 1-D, 2-D and
contiguous cases with h5py under a 2 s bound (5.8 s before, debug build).
- **CI builds and tests the Python package.** It was excluded from CI. - **CI builds and tests the Python package.** It was excluded from CI.
`scripts/ci-test.sh` now lints `clawhdf5-py`, builds the wheel with `scripts/ci-test.sh` now lints `clawhdf5-py`, builds the wheel with
maturin, unpacks it under `target/` and runs the pytest suite; skipped maturin, unpacks it under `target/` and runs the pytest suite; skipped
+24 -4
View File
@@ -31,6 +31,8 @@ pub struct PyDataset {
path: String, path: String,
/// `None` for a dataset with a null dataspace (h5py's `Empty`). /// `None` for a dataset with a null dataspace (h5py's `Empty`).
shape: Option<Vec<u64>>, shape: Option<Vec<u64>>,
/// The chunk shape, for a chunked dataset.
chunks: Option<Vec<u64>>,
datatype: Datatype, datatype: Datatype,
/// Why the datatype cannot be read into numpy, if it cannot. /// Why the datatype cannot be read into numpy, if it cannot.
conv: Result<Converter, String>, conv: Result<Converter, String>,
@@ -56,10 +58,14 @@ impl PyDataset {
}; };
let conv = Converter::new(py, &datatype, file.superblock().offset_size) let conv = Converter::new(py, &datatype, file.superblock().offset_size)
.map_err(|e| e.value(py).to_string()); .map_err(|e| e.value(py).to_string());
let chunks = shape
.as_ref()
.and_then(|s| node::chunk_shape(&file, &hdr, s.len()));
Ok(Self { Ok(Self {
file, file,
path, path,
shape, shape,
chunks,
datatype, datatype,
conv, conv,
}) })
@@ -81,17 +87,23 @@ impl PyDataset {
let arr = if plan.is_empty() { let arr = if plan.is_empty() {
conv.empty(py, &out_shape)? conv.empty(py, &out_shape)?
} else { } else {
let (reads, list_axis) = plan.reads(dims); let (vl, elem_size, unit) = (conv.is_vl(), conv.elem_size, conv.vl_unit);
let list_axis = plan.list_axis();
let chunk_len = match (&self.chunks, list_axis) {
(Some(c), Some(a)) => c.get(a).copied(),
_ => None,
};
let (reads, list_axis) = plan.reads(dims, chunk_len, elem_size);
let read_shape = plan.read_shape(); let read_shape = plan.read_shape();
let file = &*self.file; let file = &*self.file;
let path = self.path.as_str(); let path = self.path.as_str();
let (vl, elem_size, unit) = (conv.is_vl(), conv.elem_size, conv.vl_unit);
// Everything below touches only Rust data: release the GIL. // Everything below touches only Rust data: release the GIL.
let read = || -> Result<Elements, ReadError> { let read = || -> Result<Elements, ReadError> {
let ds = file.dataset(path)?; let ds = file.dataset(path)?;
let mut blocks = Vec::with_capacity(reads.len()); let mut blocks = Vec::with_capacity(reads.len());
for (sel, shape) in reads { for read in reads {
let raw = ds.read_selection(&sel)?; let raw = ds.read_selection(&read.sel)?;
let mut shape = read.shape;
let want = shape.iter().product::<usize>() * elem_size; let want = shape.iter().product::<usize>() * elem_size;
if raw.len() != want { if raw.len() != want {
return Err(ReadError::Other(format!( return Err(ReadError::Other(format!(
@@ -99,6 +111,14 @@ impl PyDataset {
raw.len() raw.len()
))); )));
} }
let raw = match (&read.pick, list_axis) {
(Some(pick), Some(axis)) => {
let kept = select::gather_along(&raw, &shape, axis, pick, elem_size);
shape[axis] = pick.len();
kept
}
_ => raw,
};
blocks.push((raw, shape)); blocks.push((raw, shape));
} }
// Several blocks only for a list index: join their bytes // Several blocks only for a list index: join their bytes
+27
View File
@@ -135,6 +135,33 @@ pub(crate) fn dataspace(file: &clawhdf5_rs::File, hdr: &ObjectHeader) -> PyResul
}) })
} }
/// The chunk shape of a chunked dataset (one entry per dataset dimension),
/// or `None` for other layouts or a layout message that does not parse.
pub(crate) fn chunk_shape(
file: &clawhdf5_rs::File,
hdr: &ObjectHeader,
rank: usize,
) -> Option<Vec<u64>> {
let sb = file.superblock();
let msg = hdr
.messages
.iter()
.find(|m| m.msg_type == MessageType::DataLayout)?;
match clawhdf5_format::data_layout::DataLayout::parse(&msg.data, sb.offset_size, sb.length_size)
.ok()?
{
clawhdf5_format::data_layout::DataLayout::Chunked {
chunk_dimensions, ..
} if chunk_dimensions.len() >= rank => Some(
chunk_dimensions[..rank]
.iter()
.map(|&d| u64::from(d))
.collect(),
),
_ => None,
}
}
pub(crate) fn is_null(space: &Dataspace) -> bool { pub(crate) fn is_null(space: &Dataspace) -> bool {
space.space_type == DataspaceType::Null space.space_type == DataspaceType::Null
} }
+163 -30
View File
@@ -62,6 +62,11 @@ impl Plan {
self.axes.iter().map(|a| a.len() as usize).collect() self.axes.iter().map(|a| a.len() as usize).collect()
} }
/// The axis indexed by a list, if any.
pub fn list_axis(&self) -> Option<usize> {
self.axes.iter().position(|a| matches!(a, Axis::List(_)))
}
/// Whether the selection is empty. /// Whether the selection is empty.
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.axes.iter().any(|a| a.len() == 0) self.axes.iter().any(|a| a.len() == 0)
@@ -69,16 +74,42 @@ impl Plan {
/// The hyperslab reads that make up this selection, each with the shape /// The hyperslab reads that make up this selection, each with the shape
/// of its block (index axes kept at length 1). More than one only when an /// of its block (index axes kept at length 1). More than one only when an
/// axis is indexed by a list: one read per run of consecutive indices, /// axis is indexed by a list; those are joined along `list_axis`
/// joined along `list_axis` afterwards (`join_along`). /// afterwards (`join_along`), after keeping each read's `pick` rows.
pub fn reads(&self, dims: &[u64]) -> (Vec<(Selection, Vec<usize>)>, Option<usize>) { ///
let list_axis = self.axes.iter().position(|a| matches!(a, Axis::List(_))); /// A list is read in groups, each one hyperslab over a stretch of the
let runs: Vec<(u64, u64)> = match list_axis.map(|i| &self.axes[i]) { /// axis, not once per index: every read decodes the chunks it touches
Some(Axis::List(idx)) => consecutive_runs(idx), /// (and lists the dataset's chunks), so a read per run of indices decoded
_ => vec![(0, 0)], /// the same chunk again and again. For a chunked dataset (`chunk_len` is
/// the chunk's length along the list axis) a group ends only where a
/// whole chunk holds no selected index, so no chunk is decoded twice or
/// without need. Otherwise a group ends at a gap of more than
/// [`MAX_GAP_BYTES`] of unselected data.
pub fn reads(
&self,
dims: &[u64],
chunk_len: Option<u64>,
elem_size: usize,
) -> (Vec<Read>, Option<usize>) {
let list_axis = self.list_axis();
let groups: Vec<&[u64]> = match list_axis.map(|i| &self.axes[i]) {
Some(Axis::List(idx)) => {
let row_bytes = self.row_bytes(elem_size);
group_indices(idx, |last, next| match chunk_len {
Some(c) if c > 0 => next / c <= last / c + 1,
_ => (next - last - 1).saturating_mul(row_bytes) <= MAX_GAP_BYTES,
})
}
_ => vec![&[]],
}; };
let mut out = Vec::with_capacity(runs.len()); let mut out = Vec::with_capacity(groups.len());
for (run_start, run_len) in runs { for group in groups {
let (first, span) = match (group.first(), group.last()) {
(Some(&f), Some(&l)) => (f, l - f + 1),
_ => (0, 0),
};
let pick = (span != group.len() as u64)
.then(|| group.iter().map(|&i| (i - first) as usize).collect());
let mut start = Vec::with_capacity(dims.len()); let mut start = Vec::with_capacity(dims.len());
let mut stride = Vec::with_capacity(dims.len()); let mut stride = Vec::with_capacity(dims.len());
let mut count = Vec::with_capacity(dims.len()); let mut count = Vec::with_capacity(dims.len());
@@ -86,14 +117,14 @@ impl Plan {
let (s, st, c) = match axis { let (s, st, c) = match axis {
Axis::Index(i) => (*i, 1, 1), Axis::Index(i) => (*i, 1, 1),
Axis::Slice { start, step, count } => (*start, *step, *count), Axis::Slice { start, step, count } => (*start, *step, *count),
Axis::List(_) => (run_start, 1, run_len), Axis::List(_) => (first, 1, span),
}; };
start.push(s); start.push(s);
// A stride only matters between blocks; keep it >= 1. // A stride only matters between blocks; keep it >= 1.
stride.push(if c <= 1 { 1 } else { st }); stride.push(if c <= 1 { 1 } else { st });
count.push(c); count.push(c);
} }
let block_shape: Vec<usize> = count.iter().map(|&c| c as usize).collect(); let shape: Vec<usize> = count.iter().map(|&c| c as usize).collect();
let whole = start.iter().all(|&s| s == 0) let whole = start.iter().all(|&s| s == 0)
&& stride.iter().all(|&s| s == 1) && stride.iter().all(|&s| s == 1)
&& count.as_slice() == dims; && count.as_slice() == dims;
@@ -108,21 +139,74 @@ impl Plan {
block, block,
} }
}; };
out.push((sel, block_shape)); out.push(Read { sel, shape, pick });
} }
(out, list_axis) (out, list_axis)
} }
/// Bytes of one step along the list axis within a read's bounding box.
fn row_bytes(&self, elem_size: usize) -> u64 {
self.axes
.iter()
.map(|a| match a {
Axis::Slice { step, count, .. } if *count > 0 => (count - 1) * step + 1,
_ => 1,
})
.fold(elem_size as u64, u64::saturating_mul)
}
} }
fn consecutive_runs(idx: &[u64]) -> Vec<(u64, u64)> { /// Unselected data a read of a non-chunked dataset copies through rather
let mut runs: Vec<(u64, u64)> = Vec::new(); /// than start another read.
for &i in idx { pub(crate) const MAX_GAP_BYTES: u64 = 64 * 1024;
match runs.last_mut() {
Some((s, n)) if *s + *n == i => *n += 1, /// One hyperslab read of a selection.
_ => runs.push((i, 1)), #[derive(Clone, Debug, PartialEq)]
pub(crate) struct Read {
pub sel: Selection,
/// The block's shape (index axes at length 1).
pub shape: Vec<usize>,
/// For a list: the positions along the list axis, within the block, to
/// keep (`None`: all of them).
pub pick: Option<Vec<usize>>,
}
/// Split increasing indices into groups; `joins(last, next)` says whether
/// `next` extends the group whose last index is `last`.
fn group_indices(idx: &[u64], joins: impl Fn(u64, u64) -> bool) -> Vec<&[u64]> {
let mut groups = Vec::new();
let mut from = 0;
for k in 1..idx.len() {
if !joins(idx[k - 1], idx[k]) {
groups.push(&idx[from..k]);
from = k;
} }
} }
runs if from < idx.len() {
groups.push(&idx[from..]);
}
groups
}
/// Keep the elements at positions `pick` along `axis` of a row-major block.
pub(crate) fn gather_along(
bytes: &[u8],
shape: &[usize],
axis: usize,
pick: &[usize],
elem_size: usize,
) -> Vec<u8> {
let outer: usize = shape[..axis].iter().product();
let inner: usize = shape[axis + 1..].iter().product::<usize>() * elem_size;
let len = shape[axis];
let mut out = Vec::with_capacity(outer * pick.len() * inner);
for o in 0..outer {
for &p in pick {
let at = (o * len + p) * inner;
out.extend_from_slice(&bytes[at..at + inner]);
}
}
out
} }
/// Join row-major blocks of `elem_size`-byte elements whose shapes differ /// Join row-major blocks of `elem_size`-byte elements whose shapes differ
@@ -336,12 +420,53 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn runs_group_consecutive_indices() { fn indices_group_by_chunk() {
let chunked = |c: u64| move |last: u64, next: u64| next / c <= last / c + 1;
// Chunks of 10: 3, 5 and 15 are in neighbouring chunks; 42 skips two.
let idx = [3, 5, 15, 42, 43, 99];
assert_eq!( assert_eq!(
consecutive_runs(&[1, 2, 3, 7, 9, 10]), group_indices(&idx, chunked(10)),
vec![(1, 3), (7, 1), (9, 2)] vec![&[3, 5, 15][..], &[42, 43], &[99]]
);
assert_eq!(group_indices(&[], chunked(10)), Vec::<&[u64]>::new());
}
#[test]
fn a_list_reads_once_per_group() {
let plan = Plan {
axes: vec![
Axis::List(vec![0, 2, 3, 40]),
Axis::Slice {
start: 0,
step: 1,
count: 5,
},
],
fields: vec![],
scalar: false,
};
// Chunks of 8 rows: rows 0-3 are one read, row 40 another.
let (reads, axis) = plan.reads(&[50, 5], Some(8), 4);
assert_eq!(axis, Some(0));
assert_eq!(reads.len(), 2);
assert_eq!(reads[0].shape, vec![4, 5]);
assert_eq!(reads[0].pick, Some(vec![0, 2, 3]));
assert_eq!(reads[1].shape, vec![1, 5]);
assert_eq!(reads[1].pick, None);
// Not chunked: a gap under MAX_GAP_BYTES is read through.
let (reads, _) = plan.reads(&[50, 5], None, 4);
assert_eq!(reads.len(), 1);
assert_eq!(reads[0].pick, Some(vec![0, 2, 3, 40]));
}
#[test]
fn gather_keeps_picked_rows() {
// A 2x3 block of 1-byte elements; keep columns 0 and 2.
let block = [1, 2, 3, 4, 5, 6];
assert_eq!(
gather_along(&block, &[2, 3], 1, &[0, 2], 1),
vec![1, 3, 4, 6]
); );
assert_eq!(consecutive_runs(&[]), vec![]);
} }
#[test] #[test]
@@ -374,9 +499,16 @@ mod tests {
fields: vec![], fields: vec![],
scalar: false, scalar: false,
}; };
let (reads, list) = plan.reads(&[4, 3]); let (reads, list) = plan.reads(&[4, 3], None, 8);
assert_eq!(list, None); assert_eq!(list, None);
assert_eq!(reads, vec![(Selection::All, vec![4, 3])]); assert_eq!(
reads,
vec![Read {
sel: Selection::All,
shape: vec![4, 3],
pick: None
}]
);
} }
#[test] #[test]
@@ -393,18 +525,19 @@ mod tests {
fields: vec![], fields: vec![],
scalar: false, scalar: false,
}; };
let (reads, _) = plan.reads(&[4, 8]); let (reads, _) = plan.reads(&[4, 8], None, 8);
assert_eq!( assert_eq!(
reads, reads,
vec![( vec![Read {
Selection::Hyperslab { sel: Selection::Hyperslab {
start: vec![2, 1], start: vec![2, 1],
stride: vec![1, 3], stride: vec![1, 3],
count: vec![1, 2], count: vec![1, 2],
block: vec![1, 1], block: vec![1, 1],
}, },
vec![1, 2] shape: vec![1, 2],
)] pick: None
}]
); );
assert_eq!(plan.out_shape(), vec![2]); assert_eq!(plan.out_shape(), vec![2]);
} }
@@ -531,3 +531,35 @@ def test_compound_padding_bytes_match_h5py(pair):
assert ours[name][key].tobytes() == theirs[name][key].tobytes(), f"{name}[{key!r}]" assert ours[name][key].tobytes() == theirs[name][key].tobytes(), f"{name}[{key!r}]"
for key in [(slice(None), [0, 2]), ([0, 2, 3], slice(None)), ([1, 3], 1)]: for key in [(slice(None), [0, 2]), ([0, 2, 3], slice(None)), ([1, 3], 1)]:
assert ours["cmp/nested_2d"][key].tobytes() == theirs["cmp/nested_2d"][key].tobytes(), key assert ours["cmp/nested_2d"][key].tobytes() == theirs["cmp/nested_2d"][key].tobytes(), key
def test_a_long_index_list_decodes_each_chunk_once(h5py, tmp_path):
"""An index list is read one group of chunks at a time, not one
hyperslab per run of indices: 5000 runs over 20 gzip chunks used to
decode the chunks 5000 times (8 s, against h5py's 0.014 s)."""
import time
path = str(tmp_path / "long_list.h5")
data = np.arange(200000, dtype="<f8")
grid = np.arange(400 * 3000, dtype="<i4").reshape(400, 3000)
with h5py.File(path, "w") as f:
f.create_dataset("d", data=data, chunks=(10000,), compression="gzip")
f.create_dataset("grid", data=grid, chunks=(50, 100), compression="gzip")
f.create_dataset("flat", data=data) # contiguous
rng = np.random.default_rng(3)
cases = [
("d", list(range(0, 200000, 40))),
("d", sorted(rng.choice(200000, 3000, replace=False).tolist())),
("flat", list(range(0, 200000, 40))),
("flat", [0, 7, 199999]),
("grid", (slice(None), list(range(0, 3000, 3)))),
("grid", (sorted(rng.choice(400, 150, replace=False).tolist()), slice(5, 2900, 7))),
("grid", (7, [0, 1, 2, 2000, 2999])),
]
with h5py.File(path, "r") as theirs, clawhdf5.File(path, "r") as ours:
for name, key in cases:
t0 = time.perf_counter()
got = ours[name][key]
took = time.perf_counter() - t0
assert_same(got, theirs[name][key], f"{name}[{len(key)}-key]")
assert took < 2.0, f"{name}: {took:.2f} s"