edit: shrink by visiting the chunks that exist
prune_plan stored one Vec<u64> for every chunk coordinate of the region a shrink cuts off, existing or not, so a sparse dataset exhausted memory (about 62 bytes per coordinate; (4, 2e7) with chunks (1, 1) took 2.5 GB, larger extents never finished). It now places each existing chunk in H5D__chunk_prune_by_extent's walk (its pass, then its coordinates) and sorts, which gives the same chunks, order and actions in memory and time proportional to the chunks that exist. A unit test checks the plan against the full walk (kept as the test's reference) for 3000 random extents and chunk subsets. The interop test shrinks a (4, 10^12) dataset with chunks (1, 1) and 9 chunks (v1 and v2 B-tree): 0.56 s and 43 MB peak; the old code aborted on allocation under an 8 GB limit. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
@@ -1566,3 +1566,65 @@ fn resize_clawhdf5_written_datasets() {
|
||||
}
|
||||
check_tools(&path, true);
|
||||
}
|
||||
|
||||
/// Shrinking a huge, sparse dataset costs time and memory in the chunks
|
||||
/// that exist, not in the chunk coordinates cut off. The editor once stored
|
||||
/// every coordinate of the cut-off region (about 62 bytes each), so this
|
||||
/// 2 x 10^12-coordinate shrink ran out of memory.
|
||||
#[test]
|
||||
fn shrinking_a_huge_sparse_dataset_is_bounded() {
|
||||
if !tools_ok() {
|
||||
return;
|
||||
}
|
||||
let dir = tmpdir();
|
||||
const N: u64 = 1_000_000_000_000;
|
||||
for libver in ["earliest", "v110"] {
|
||||
let path = dir.path().join(format!("sparse_{libver}.h5"));
|
||||
py(&format!(
|
||||
"import h5py\n\
|
||||
with h5py.File({p:?}, 'w', libver=({libver:?}, 'latest')) as f:\n\
|
||||
\x20 d = f.create_dataset('b', shape=(4, {N}), maxshape=(None, None), chunks=(1, 1), dtype='<i4')\n\
|
||||
\x20 d[0, 0:5] = [1, 2, 3, 4, 5]\n\
|
||||
\x20 d[0, 900000000000] = 6\n\
|
||||
\x20 d[1, {N} - 1] = 7\n\
|
||||
\x20 d[2, 7] = 8\n\
|
||||
\x20 d[3, 500000000000] = 9\n\
|
||||
\x20 assert d.id.get_num_chunks() == 9\n",
|
||||
p = path.to_str().unwrap(),
|
||||
));
|
||||
let start = std::time::Instant::now();
|
||||
let mut ed = FileEditor::open(&path).unwrap();
|
||||
ed.resize("b", &[2, 600_000_000_000]).unwrap();
|
||||
drop(ed);
|
||||
let took = start.elapsed();
|
||||
assert!(
|
||||
took < std::time::Duration::from_secs(30),
|
||||
"{libver}: shrink took {took:?}"
|
||||
);
|
||||
py(&format!(
|
||||
"import h5py\n\
|
||||
with h5py.File({p:?}, 'r') as f:\n\
|
||||
\x20 d = f['b']\n\
|
||||
\x20 assert d.shape == (2, 600000000000), d.shape\n\
|
||||
\x20 assert d.id.get_num_chunks() == 5, d.id.get_num_chunks()\n\
|
||||
\x20 assert list(d[0, 0:6]) == [1, 2, 3, 4, 5, 0], d[0, 0:6]\n\
|
||||
\x20 assert d[1, 599999999999] == 0\n\
|
||||
with h5py.File({p:?}, 'r+') as f:\n\
|
||||
\x20 d = f['b']\n\
|
||||
\x20 d.resize((4, {N}))\n\
|
||||
\x20 assert d[0, 900000000000] == 0 and d[1, {N} - 1] == 0\n\
|
||||
\x20 assert d[2, 7] == 0 and d[3, 500000000000] == 0\n",
|
||||
p = path.to_str().unwrap(),
|
||||
));
|
||||
let f = File::open(&path).unwrap();
|
||||
let ds = f.dataset("b").unwrap();
|
||||
let got = ds.read_selection(&block(&[0, 0], &[2, 6])).unwrap();
|
||||
let got: Vec<i32> = got
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| i32::from_le_bytes(*c))
|
||||
.collect();
|
||||
assert_eq!(got, [1, 2, 3, 4, 5, 0, 0, 0, 0, 0, 0, 0], "{libver}");
|
||||
}
|
||||
}
|
||||
|
||||
+179
-67
@@ -1330,16 +1330,31 @@ enum Prune {
|
||||
Remove,
|
||||
}
|
||||
|
||||
/// The chunks `H5D__chunk_prune_by_extent` visits when the extent shrinks
|
||||
/// from `old` to `new`, in its order.
|
||||
fn prune_plan(old: &[u64], new: &[u64], cd: &[u64]) -> Vec<(Vec<u64>, Prune)> {
|
||||
/// The chunks of `existing` (keyed by scaled coordinates) that
|
||||
/// `H5D__chunk_prune_by_extent` visits when the extent shrinks from `old`
|
||||
/// to `new`, in its order, with what it does to each.
|
||||
///
|
||||
/// libhdf5 walks every chunk coordinate of the region cut off, one pass per
|
||||
/// shrunk dimension `op` in order: the coordinates with `scaled[op]` at or
|
||||
/// past the new extent's chunk and every earlier shrunk dimension below its
|
||||
/// own, row-major. It looks each one up in the index and stores nothing,
|
||||
/// so a sparse dataset costs it time, not memory. Only chunks that exist
|
||||
/// can be acted on, so this places each existing chunk in that walk (its
|
||||
/// pass, then its coordinates) and sorts: the same chunks in the same
|
||||
/// order, in memory and time proportional to the chunks that exist.
|
||||
fn prune_plan<'c>(
|
||||
old: &[u64],
|
||||
new: &[u64],
|
||||
cd: &[u64],
|
||||
existing: &'c HashMap<Vec<u64>, ChunkInfo>,
|
||||
) -> Vec<(&'c [u64], &'c ChunkInfo, Prune)> {
|
||||
let rank = new.len();
|
||||
let mut out = Vec::new();
|
||||
if old.contains(&0) {
|
||||
return out;
|
||||
if old.contains(&0) || cd.contains(&0) {
|
||||
return Vec::new();
|
||||
}
|
||||
let shrunk: Vec<bool> = (0..rank).map(|d| new[d] < old[d]).collect();
|
||||
let mut max_mod: Vec<u64> = (0..rank).map(|d| (old[d] - 1) / cd[d]).collect();
|
||||
let max_mod: Vec<u64> = (0..rank).map(|d| (old[d] - 1) / cd[d]).collect();
|
||||
// The last chunk index still (partly) inside the new extent; -1 when
|
||||
// the dimension shrank to nothing.
|
||||
let max_fill: Vec<i64> = (0..rank)
|
||||
.map(|d| {
|
||||
if new[d] == 0 {
|
||||
@@ -1350,60 +1365,25 @@ fn prune_plan(old: &[u64], new: &[u64], cd: &[u64]) -> Vec<(Vec<u64>, Prune)> {
|
||||
})
|
||||
.collect();
|
||||
let min_mod: Vec<u64> = (0..rank).map(|d| new[d] / cd[d]).collect();
|
||||
let fill_dim: Vec<bool> = (0..rank)
|
||||
.map(|d| shrunk[d] && min_mod[d] as i64 == max_fill[d])
|
||||
.collect();
|
||||
for op in 0..rank {
|
||||
if !shrunk[op] {
|
||||
continue;
|
||||
}
|
||||
let mut scaled = vec![0u64; rank];
|
||||
scaled[op] = min_mod[op];
|
||||
let mut outside: Vec<bool> = (0..rank).map(|u| scaled[u] as i64 > max_fill[u]).collect();
|
||||
let mut n_out = outside.iter().filter(|&&o| o).count();
|
||||
loop {
|
||||
if n_out == 0 {
|
||||
out.push((scaled.clone(), Prune::Fill));
|
||||
let mut out: Vec<(usize, &[u64], &ChunkInfo, Prune)> = existing
|
||||
.iter()
|
||||
.filter(|(s, _)| s.len() == rank && s.iter().zip(&max_mod).all(|(c, m)| c <= m))
|
||||
.filter_map(|(s, info)| {
|
||||
// The first shrunk dimension whose pass reaches this chunk; the
|
||||
// passes before it covered only coordinates below their minimum.
|
||||
let op = (0..rank).find(|&d| new[d] < old[d] && s[d] >= min_mod[d])?;
|
||||
// Chunks with any coordinate past the last partly kept chunk go;
|
||||
// the others lose the part outside the new extent.
|
||||
let what = if (0..rank).all(|u| s[u] as i64 <= max_fill[u]) {
|
||||
Prune::Fill
|
||||
} else {
|
||||
out.push((scaled.clone(), Prune::Remove));
|
||||
}
|
||||
let mut carry = true;
|
||||
for i in (0..rank).rev() {
|
||||
scaled[i] += 1;
|
||||
if scaled[i] > max_mod[i] {
|
||||
if i == op {
|
||||
scaled[i] = min_mod[i];
|
||||
if outside[i] && fill_dim[i] {
|
||||
outside[i] = false;
|
||||
n_out -= 1;
|
||||
}
|
||||
} else {
|
||||
scaled[i] = 0;
|
||||
if outside[i] && max_fill[i] >= 0 {
|
||||
outside[i] = false;
|
||||
n_out -= 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !outside[i] && scaled[i] as i64 > max_fill[i] {
|
||||
outside[i] = true;
|
||||
n_out += 1;
|
||||
}
|
||||
carry = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if carry {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if min_mod[op] == 0 {
|
||||
// Every chunk was visited (the dimension shrank to nothing).
|
||||
break;
|
||||
}
|
||||
max_mod[op] = min_mod[op] - 1;
|
||||
}
|
||||
out
|
||||
Prune::Remove
|
||||
};
|
||||
Some((op, s.as_slice(), info, what))
|
||||
})
|
||||
.collect();
|
||||
out.sort_unstable_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
|
||||
out.into_iter().map(|(_, s, i, w)| (s, i, w)).collect()
|
||||
}
|
||||
|
||||
/// The chunk work of a resize: under early allocation, allocate and fill
|
||||
@@ -1468,12 +1448,9 @@ fn resize_chunks(
|
||||
})?;
|
||||
}
|
||||
if shrink && !existing.is_empty() {
|
||||
for (scaled, what) in prune_plan(old, new, &cd) {
|
||||
let Some(info) = existing.get(&scaled) else {
|
||||
continue;
|
||||
};
|
||||
for (scaled, info, what) in prune_plan(old, new, &cd, &existing) {
|
||||
match what {
|
||||
Prune::Remove => ce.remove(img, &scaled, info)?,
|
||||
Prune::Remove => ce.remove(img, scaled, info)?,
|
||||
Prune::Fill => {
|
||||
let mut buf = decode_chunk(img_read(f, info)?, t, info, chunk_bytes)?;
|
||||
// Keep [0, count) in each dimension; fill the rest.
|
||||
@@ -1495,7 +1472,7 @@ fn resize_chunks(
|
||||
buf[at..at + es].copy_from_slice(&t.fill);
|
||||
}
|
||||
}
|
||||
store_chunk(img, &mut ce, Some(info), &scaled, buf)?;
|
||||
store_chunk(img, &mut ce, Some(info), scaled, buf)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1610,6 +1587,141 @@ fn decode_chunk(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Prune, prune_plan};
|
||||
use clawhdf5_format::chunked_read::ChunkInfo;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `H5D__chunk_prune_by_extent`'s walk over every chunk coordinate of the
|
||||
/// region cut off, stored (the implementation before `prune_plan`
|
||||
/// visited only existing chunks).
|
||||
fn prune_walk(old: &[u64], new: &[u64], cd: &[u64]) -> Vec<(Vec<u64>, Prune)> {
|
||||
let rank = new.len();
|
||||
let mut out = Vec::new();
|
||||
if old.contains(&0) {
|
||||
return out;
|
||||
}
|
||||
let shrunk: Vec<bool> = (0..rank).map(|d| new[d] < old[d]).collect();
|
||||
let mut max_mod: Vec<u64> = (0..rank).map(|d| (old[d] - 1) / cd[d]).collect();
|
||||
let max_fill: Vec<i64> = (0..rank)
|
||||
.map(|d| {
|
||||
if new[d] == 0 {
|
||||
-1
|
||||
} else {
|
||||
((new[d].min(old[d]) - 1) / cd[d]) as i64
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let min_mod: Vec<u64> = (0..rank).map(|d| new[d] / cd[d]).collect();
|
||||
let fill_dim: Vec<bool> = (0..rank)
|
||||
.map(|d| shrunk[d] && min_mod[d] as i64 == max_fill[d])
|
||||
.collect();
|
||||
for op in 0..rank {
|
||||
if !shrunk[op] {
|
||||
continue;
|
||||
}
|
||||
let mut scaled = vec![0u64; rank];
|
||||
scaled[op] = min_mod[op];
|
||||
let mut outside: Vec<bool> =
|
||||
(0..rank).map(|u| scaled[u] as i64 > max_fill[u]).collect();
|
||||
let mut n_out = outside.iter().filter(|&&o| o).count();
|
||||
loop {
|
||||
if n_out == 0 {
|
||||
out.push((scaled.clone(), Prune::Fill));
|
||||
} else {
|
||||
out.push((scaled.clone(), Prune::Remove));
|
||||
}
|
||||
let mut carry = true;
|
||||
for i in (0..rank).rev() {
|
||||
scaled[i] += 1;
|
||||
if scaled[i] > max_mod[i] {
|
||||
if i == op {
|
||||
scaled[i] = min_mod[i];
|
||||
if outside[i] && fill_dim[i] {
|
||||
outside[i] = false;
|
||||
n_out -= 1;
|
||||
}
|
||||
} else {
|
||||
scaled[i] = 0;
|
||||
if outside[i] && max_fill[i] >= 0 {
|
||||
outside[i] = false;
|
||||
n_out -= 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !outside[i] && scaled[i] as i64 > max_fill[i] {
|
||||
outside[i] = true;
|
||||
n_out += 1;
|
||||
}
|
||||
carry = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if carry {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if min_mod[op] == 0 {
|
||||
// Every chunk was visited (the dimension shrank to nothing).
|
||||
break;
|
||||
}
|
||||
max_mod[op] = min_mod[op] - 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `prune_plan` over a random subset of chunks gives the chunks of the
|
||||
/// full walk that exist, in its order and with its actions.
|
||||
#[test]
|
||||
fn prune_plan_follows_the_full_walk() {
|
||||
let mut x: u64 = 0x1234_5678;
|
||||
let mut rnd = |n: u64| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
x % n
|
||||
};
|
||||
for _ in 0..3000 {
|
||||
let rank = 1 + rnd(3) as usize;
|
||||
let cd: Vec<u64> = (0..rank).map(|_| 1 + rnd(4)).collect();
|
||||
let old: Vec<u64> = (0..rank).map(|_| 1 + rnd(14)).collect();
|
||||
let new: Vec<u64> = old
|
||||
.iter()
|
||||
.map(|&o| if rnd(2) == 0 { o } else { rnd(o + 1) })
|
||||
.collect();
|
||||
let grid: Vec<u64> = (0..rank).map(|d| old[d].div_ceil(cd[d])).collect();
|
||||
let mut existing = HashMap::new();
|
||||
let total: u64 = grid.iter().product();
|
||||
for flat in 0..total {
|
||||
if rnd(3) == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut r = flat;
|
||||
let mut s = vec![0; rank];
|
||||
for d in (0..rank).rev() {
|
||||
s[d] = r % grid[d];
|
||||
r /= grid[d];
|
||||
}
|
||||
let info = ChunkInfo {
|
||||
chunk_size: 1,
|
||||
filter_mask: 0,
|
||||
offsets: s.iter().zip(&cd).map(|(a, b)| a * b).collect(),
|
||||
address: flat,
|
||||
};
|
||||
existing.insert(s, info);
|
||||
}
|
||||
let want: Vec<(Vec<u64>, bool)> = prune_walk(&old, &new, &cd)
|
||||
.into_iter()
|
||||
.filter(|(s, _)| existing.contains_key(s))
|
||||
.map(|(s, w)| (s, matches!(w, Prune::Fill)))
|
||||
.collect();
|
||||
let got: Vec<(Vec<u64>, bool)> = prune_plan(&old, &new, &cd, &existing)
|
||||
.into_iter()
|
||||
.map(|(s, _, w)| (s.to_vec(), matches!(w, Prune::Fill)))
|
||||
.collect();
|
||||
assert_eq!(got, want, "old {old:?} new {new:?} chunks {cd:?}");
|
||||
}
|
||||
}
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::path::Path;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user