fix(format): never hold a B2ND chunk's padding
B2ND chunks were decoded whole, padding included, with up to 16x the HDF5 chunk size as their limit, so a crafted frame made each chunk allocate and fill up to 16x the output (4 GiB for a 256 MiB HDF5 chunk). The padding is the real bound (prod(ceil(c/b)*b) per chunk), but that can be 2^ndim times the array, so it is no longer held at all: - A Blosc2 chunk is now decoded block by block (decode_blocks), each block handed to a sink as it is ready, with at most three blocks of scratch. blosc2_decompress_chunk and plain frames still collect every block. - reassemble places each B2ND block straight into the output and skips blocks that are all padding (they are not decoded unless the delta filter needs the first block). A frame's NaN chunks are handed over one B2ND block at a time and its zero chunks cost nothing. - A B2ND chunk must decode to exactly its padded size, its Blosc2 blocks must be whole B2ND blocks no larger than the output, and a chunk may not be larger than the array (hdf5-blosc2's chunk is the array), so a block is never larger than the output. Peak allocation for a 10-D array padded to 13x (NaN, repeated-value and stored-block chunks) and for a 16x chunk was 4.5 MB and 17.8 MB for 315 KB and 1 MiB outputs before, and is now within the tests' bound. Blosc2 files written by hdf5plugin in 9-D and 12-D, an 8 MiB single chunk, 1x1x1 and edge-chunk shapes still read exactly. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
@@ -220,3 +220,131 @@ fn empty_chunk_does_not_allocate_its_block_size() {
|
||||
assert!(r.is_err(), "decoded {:?}", r.map(|v| v.len()));
|
||||
assert!(peak <= bound(64, &f), "in a frame: peak {peak} bytes");
|
||||
}
|
||||
|
||||
/// B2ND chunks were decoded whole, padding included, with up to 16x the
|
||||
/// HDF5 chunk size as their limit. Blocks are now placed as they are
|
||||
/// decoded, so the padding is never held.
|
||||
///
|
||||
/// Ten dimensions: nine of 3 split into blocks of 2 (padded to 4) and one
|
||||
/// of 4, so each chunk is 13x the array. One chunk, stored three ways: as a
|
||||
/// NaN chunk in the frame's offsets, as a repeated-value chunk, and as a
|
||||
/// chunk of stored (uncompressed) blocks.
|
||||
#[test]
|
||||
fn b2nd_padding_is_never_held() {
|
||||
let _g = lock();
|
||||
let ts = 4usize;
|
||||
let mut shape = vec![3i64; 9];
|
||||
shape.push(4);
|
||||
let chunks: Vec<i32> = shape.iter().map(|&s| s as i32).collect();
|
||||
let mut blocks = vec![2i32; 9];
|
||||
blocks.push(4);
|
||||
let meta = nd_meta(&shape, &chunks, &blocks);
|
||||
let items: usize = shape.iter().product::<i64>() as usize;
|
||||
let limit = items * ts;
|
||||
let block_bytes = ts * blocks.iter().product::<i32>() as usize;
|
||||
let ext_bytes = ts * 4usize.pow(9) * 4;
|
||||
assert!(ext_bytes > 13 * limit);
|
||||
let offsets = |off: [u8; 8]| repeated(&off, 8, 8);
|
||||
|
||||
let value = 1.5f32.to_le_bytes();
|
||||
let stored = {
|
||||
// Every block stored raw: block k holds the value k.
|
||||
let mut c = chunk_header(4, ext_bytes as i32, block_bytes as i32, 0, 0);
|
||||
c[2] = 0x02 | 0x10; // memcpyed, not split
|
||||
c.truncate(16);
|
||||
for k in 0..ext_bytes / block_bytes {
|
||||
c.extend((k as f32).to_le_bytes().repeat(block_bytes / 4));
|
||||
}
|
||||
let n = c.len() as i32;
|
||||
c[12..16].copy_from_slice(&n.to_le_bytes());
|
||||
c
|
||||
};
|
||||
let cases: Vec<(&str, Vec<u8>)> = vec![
|
||||
(
|
||||
"NaN offset",
|
||||
frame(
|
||||
Some(&meta),
|
||||
ext_bytes as i64,
|
||||
4,
|
||||
ext_bytes as i32,
|
||||
&[],
|
||||
&offsets(special_offset(2)),
|
||||
),
|
||||
),
|
||||
(
|
||||
"repeated value",
|
||||
frame(
|
||||
Some(&meta),
|
||||
ext_bytes as i64,
|
||||
4,
|
||||
ext_bytes as i32,
|
||||
&repeated(&value, ext_bytes as i32, block_bytes as i32),
|
||||
&offsets(0i64.to_le_bytes()),
|
||||
),
|
||||
),
|
||||
(
|
||||
"stored blocks",
|
||||
frame(
|
||||
Some(&meta),
|
||||
ext_bytes as i64,
|
||||
4,
|
||||
ext_bytes as i32,
|
||||
&stored,
|
||||
&offsets(0i64.to_le_bytes()),
|
||||
),
|
||||
),
|
||||
];
|
||||
for (name, f) in cases {
|
||||
let (r, peak) = peak_during(|| blosc2_decompress(&f, limit));
|
||||
let out = r.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(out.len(), limit, "{name}");
|
||||
match name {
|
||||
"NaN offset" => assert!(
|
||||
out.chunks(4)
|
||||
.all(|v| f32::from_le_bytes(v.try_into().unwrap()).is_nan())
|
||||
),
|
||||
"repeated value" => assert!(out.chunks(4).all(|v| v == value)),
|
||||
_ => {
|
||||
// Element (i0..i9) lies in block (i0/2, .., i8/2), numbered
|
||||
// in C order over a 2x..x2x1 grid of blocks.
|
||||
let mut idx = [0usize; 10];
|
||||
for (e, v) in out.chunks(4).enumerate() {
|
||||
let mut n = e;
|
||||
for d in (0..10).rev() {
|
||||
idx[d] = n % shape[d] as usize;
|
||||
n /= shape[d] as usize;
|
||||
}
|
||||
let k = idx[..9].iter().fold(0, |k, &i| k * 2 + i / 2);
|
||||
assert_eq!(
|
||||
f32::from_le_bytes(v.try_into().unwrap()),
|
||||
k as f32,
|
||||
"{name} {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
peak <= bound(limit, &f),
|
||||
"{name}: peak {peak} bytes for a {limit}-byte chunk ({}-byte frame)",
|
||||
f.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A B2ND chunk larger than the array (here 16x, the old cap) is refused,
|
||||
/// or at least never allocated.
|
||||
#[test]
|
||||
fn b2nd_chunk_larger_than_the_array_is_not_allocated() {
|
||||
let _g = lock();
|
||||
let limit = 1 << 20;
|
||||
let c = 16 * limit as i32;
|
||||
let meta = nd_meta(&[limit as i64], &[c], &[c]);
|
||||
let offsets = repeated(&special_offset(1), 8, 8);
|
||||
let f = frame(Some(&meta), c as i64, 1, c, &[], &offsets);
|
||||
let (r, peak) = peak_during(|| blosc2_decompress(&f, limit));
|
||||
assert!(
|
||||
peak <= bound(limit, &f),
|
||||
"peak {peak} bytes ({:?})",
|
||||
r.map(|v| v.len())
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user