CI / test (push) Failing after 15s
- Add .gitea/workflows/ci.yml running scripts/ci-test.sh (fmt, clippy,
test, no_std check) on push/PR to main.
- Fix stale rustyhdf5-py/rustyhdf5-format package names in
ci-test.sh/check-nostd.sh, which had been silently no-op'ing those
checks (cargo warns but doesn't fail on an unknown --exclude/-p
target).
- With those checks actually running, fix the real issues they surface:
- clippy: useless_conversion in chunked_write.rs, byte_char_slices in
global_heap.rs/object_header.rs.
- cargo fmt: apply formatting across the workspace (whitespace only).
- no_std (thumbv7em-none-eabihf) build errors in clawhdf5-format:
core::sync::atomic::AtomicU64 doesn't exist on that target (no
native 64-bit atomics) — switch profiling.rs's counters to
portable-atomic, which falls back to a CAS-based emulation there
and is a no-op wrapper elsewhere. Add missing alloc imports for
Box (filters.rs), Vec (filters_szip.rs), and format! (dict_encoding.rs)
on no_std paths. Replace f64::powi (std/libm-only) with a small
local exponentiation-by-squaring helper in the scale-offset filter.
1587 lines
52 KiB
Rust
1587 lines
52 KiB
Rust
//! Chunked dataset writing: chunk splitting, compression, index building.
|
||
|
||
#[cfg(not(feature = "std"))]
|
||
extern crate alloc;
|
||
|
||
#[cfg(not(feature = "std"))]
|
||
use alloc::{vec, vec::Vec};
|
||
|
||
use crate::checksum::jenkins_lookup3;
|
||
use crate::chunk_cache::{CACHE_LINE_SIZE, align_to_cache_line};
|
||
use crate::ea_writer;
|
||
use crate::error::FormatError;
|
||
use crate::filter_pipeline::{
|
||
FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_PCODEC, FILTER_SHUFFLE, FILTER_ZSTD,
|
||
FilterDescription, FilterPipeline,
|
||
};
|
||
use crate::filters::compress_chunk;
|
||
|
||
/// Round a file offset up to the next cache-line boundary.
|
||
///
|
||
/// This ensures chunk data starts at an address that is a multiple of the
|
||
/// architecture's cache line size, enabling aligned loads in SIMD paths.
|
||
#[inline]
|
||
pub fn align_chunk_offset(offset: u64) -> u64 {
|
||
let align = CACHE_LINE_SIZE as u64;
|
||
(offset + align - 1) & !(align - 1)
|
||
}
|
||
|
||
/// Options for chunked dataset creation.
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct ChunkOptions {
|
||
/// Chunk dimensions (one per dataset dimension).
|
||
pub chunk_dims: Option<Vec<u64>>,
|
||
/// Deflate compression level (0-9), None = no deflate.
|
||
pub deflate_level: Option<u32>,
|
||
/// Whether to apply shuffle filter before compression.
|
||
/// If `false` AND compression is enabled AND `no_shuffle` is `false`,
|
||
/// shuffle is auto-applied (matches h5py default behavior).
|
||
pub shuffle: bool,
|
||
/// Disable the automatic shuffle pre-filter. Set via `without_shuffle()`.
|
||
pub no_shuffle: bool,
|
||
/// Whether to apply fletcher32 checksum.
|
||
pub fletcher32: bool,
|
||
/// Whether to use LZ4 compression (filter ID 32004).
|
||
pub lz4: bool,
|
||
/// Zstandard compression level (1-22), None = no zstd. Filter ID 32015.
|
||
pub zstd_level: Option<u32>,
|
||
/// Pcodec lossless numerical compression. Filter ID 32023.
|
||
pub pcodec: bool,
|
||
}
|
||
|
||
impl ChunkOptions {
|
||
/// Whether any chunking option is enabled.
|
||
pub fn is_chunked(&self) -> bool {
|
||
self.chunk_dims.is_some()
|
||
|| self.deflate_level.is_some()
|
||
|| self.shuffle
|
||
|| self.fletcher32
|
||
|| self.lz4
|
||
|| self.zstd_level.is_some()
|
||
|| self.pcodec
|
||
}
|
||
|
||
/// Build a FilterPipeline from the options.
|
||
pub fn build_pipeline(&self, element_size: u32) -> Option<FilterPipeline> {
|
||
let mut filters = Vec::new();
|
||
|
||
let has_compression =
|
||
self.deflate_level.is_some() || self.zstd_level.is_some() || self.lz4 || self.pcodec;
|
||
|
||
// Shuffle before compression. Applied if explicitly requested OR if compression
|
||
// is active and the caller hasn't disabled it — matches h5py default behavior
|
||
// and implements TDT byte-grouping (arXiv:2506.18062) for free.
|
||
if self.shuffle || (has_compression && !self.no_shuffle) {
|
||
filters.push(FilterDescription {
|
||
filter_id: FILTER_SHUFFLE,
|
||
name: None,
|
||
flags: 0,
|
||
client_data: vec![element_size],
|
||
});
|
||
}
|
||
|
||
// Compression filters (mutually exclusive, priority: pcodec > zstd > lz4 > deflate)
|
||
if self.pcodec {
|
||
filters.push(FilterDescription {
|
||
filter_id: FILTER_PCODEC,
|
||
name: Some("pcodec".into()),
|
||
flags: 0,
|
||
client_data: vec![element_size],
|
||
});
|
||
} else if let Some(level) = self.zstd_level {
|
||
filters.push(FilterDescription {
|
||
filter_id: FILTER_ZSTD,
|
||
name: Some("zstd".into()),
|
||
flags: 0,
|
||
client_data: vec![level],
|
||
});
|
||
} else if self.lz4 {
|
||
filters.push(FilterDescription {
|
||
filter_id: FILTER_LZ4,
|
||
name: Some("lz4".into()),
|
||
flags: 0,
|
||
client_data: vec![],
|
||
});
|
||
} else if let Some(level) = self.deflate_level {
|
||
filters.push(FilterDescription {
|
||
filter_id: FILTER_DEFLATE,
|
||
name: None,
|
||
flags: 0,
|
||
client_data: vec![level],
|
||
});
|
||
}
|
||
|
||
if self.fletcher32 {
|
||
filters.push(FilterDescription {
|
||
filter_id: FILTER_FLETCHER32,
|
||
name: None,
|
||
flags: 0,
|
||
client_data: vec![],
|
||
});
|
||
}
|
||
|
||
// Note: h5py sets flags=0x0001 (optional) on filters, but this is not required
|
||
// for read compatibility.
|
||
|
||
if filters.is_empty() {
|
||
None
|
||
} else {
|
||
Some(FilterPipeline {
|
||
version: 2,
|
||
filters,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Determine chunk dimensions, using user-specified or auto-computing.
|
||
pub fn resolve_chunk_dims(&self, shape: &[u64]) -> Vec<u64> {
|
||
if let Some(ref dims) = self.chunk_dims {
|
||
dims.clone()
|
||
} else {
|
||
// Auto chunk: use the full dataset shape (single chunk)
|
||
shape.to_vec()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A chunk that has been written to the file buffer.
|
||
#[derive(Debug, Clone)]
|
||
pub struct WrittenChunk {
|
||
/// Address within the file where chunk data starts.
|
||
pub address: u64,
|
||
/// Size of the (possibly compressed) chunk data in bytes.
|
||
pub compressed_size: u64,
|
||
/// Original uncompressed size in bytes.
|
||
pub raw_size: u64,
|
||
/// Filter mask (0 = all filters applied).
|
||
pub filter_mask: u32,
|
||
}
|
||
|
||
/// Result of building a chunked dataset.
|
||
pub struct ChunkedDataResult {
|
||
/// Raw bytes containing all chunk data + index structures.
|
||
pub data_bytes: Vec<u8>,
|
||
/// The DataLayout v4 message bytes.
|
||
pub layout_message: Vec<u8>,
|
||
/// The FilterPipeline message bytes, if any.
|
||
pub pipeline_message: Option<Vec<u8>>,
|
||
}
|
||
|
||
/// Split raw data into chunk-sized pieces based on shape and chunk dimensions.
|
||
/// Returns a Vec of (chunk_offset_per_dim, chunk_raw_bytes).
|
||
pub fn split_into_chunks(
|
||
raw_data: &[u8],
|
||
shape: &[u64],
|
||
chunk_dims: &[u64],
|
||
element_size: usize,
|
||
) -> Vec<(Vec<u64>, Vec<u8>)> {
|
||
let rank = shape.len();
|
||
if rank == 0 {
|
||
return vec![(vec![], raw_data.to_vec())];
|
||
}
|
||
|
||
// Compute number of chunks per dimension
|
||
let mut num_chunks_per_dim = Vec::with_capacity(rank);
|
||
for d in 0..rank {
|
||
num_chunks_per_dim.push(shape[d].div_ceil(chunk_dims[d]));
|
||
}
|
||
let total_chunks: u64 = num_chunks_per_dim.iter().product();
|
||
|
||
// Dataset strides (row-major)
|
||
let mut ds_strides = vec![1usize; rank];
|
||
for i in (0..rank.saturating_sub(1)).rev() {
|
||
ds_strides[i] = ds_strides[i + 1] * shape[i + 1] as usize;
|
||
}
|
||
|
||
// Chunk strides
|
||
let mut chunk_strides = vec![1usize; rank];
|
||
for i in (0..rank.saturating_sub(1)).rev() {
|
||
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1] as usize;
|
||
}
|
||
|
||
let chunk_total_elements: usize = chunk_dims.iter().map(|&d| d as usize).product();
|
||
|
||
let mut result = Vec::with_capacity(total_chunks as usize);
|
||
|
||
for linear_idx in 0..total_chunks {
|
||
// Convert linear index to chunk grid coordinates
|
||
let mut chunk_grid_coords = vec![0u64; rank];
|
||
let mut remaining = linear_idx;
|
||
for d in (0..rank).rev() {
|
||
chunk_grid_coords[d] = remaining % num_chunks_per_dim[d];
|
||
remaining /= num_chunks_per_dim[d];
|
||
}
|
||
|
||
// Chunk offset in dataset space
|
||
let offsets: Vec<u64> = (0..rank)
|
||
.map(|d| chunk_grid_coords[d] * chunk_dims[d])
|
||
.collect();
|
||
|
||
// Extract chunk data
|
||
let mut chunk_bytes = vec![0u8; chunk_total_elements * element_size];
|
||
|
||
for flat_idx in 0..chunk_total_elements {
|
||
let mut remaining_idx = flat_idx;
|
||
let mut ds_flat = 0usize;
|
||
let mut out_of_bounds = false;
|
||
|
||
for d in 0..rank {
|
||
let coord_in_chunk = remaining_idx / chunk_strides[d];
|
||
remaining_idx %= chunk_strides[d];
|
||
|
||
let global_coord = offsets[d] as usize + coord_in_chunk;
|
||
if global_coord >= shape[d] as usize {
|
||
out_of_bounds = true;
|
||
break;
|
||
}
|
||
ds_flat += global_coord * ds_strides[d];
|
||
}
|
||
|
||
if out_of_bounds {
|
||
// Zero-filled (already initialized)
|
||
continue;
|
||
}
|
||
|
||
let src_start = ds_flat * element_size;
|
||
let dst_start = flat_idx * element_size;
|
||
|
||
if src_start + element_size <= raw_data.len() {
|
||
chunk_bytes[dst_start..dst_start + element_size]
|
||
.copy_from_slice(&raw_data[src_start..src_start + element_size]);
|
||
}
|
||
}
|
||
|
||
result.push((offsets, chunk_bytes));
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
/// Parallel compression threshold: use rayon when chunk count exceeds this.
|
||
///
|
||
/// Lowered to 2 to enable parallel compression for typical 4-chunk workloads
|
||
/// (e.g., 128×128 matrix with 32-row chunks = 4 chunks). Rayon's overhead is
|
||
/// ~2 µs, worthwhile at ≥2 chunks with any real compression (arXiv:2206.14761).
|
||
#[cfg(feature = "parallel")]
|
||
const PARALLEL_COMPRESS_THRESHOLD: usize = 2;
|
||
|
||
/// Compress all chunks, using parallel compression when beneficial.
|
||
///
|
||
/// With the `parallel` feature and more than [`PARALLEL_COMPRESS_THRESHOLD`]
|
||
/// filtered chunks, compression runs across rayon threads; otherwise it is
|
||
/// sequential. Output order matches input order, so per-chunk bytes are
|
||
/// identical to the sequential path.
|
||
fn compress_all_chunks(
|
||
chunks: &[(Vec<u64>, Vec<u8>)],
|
||
pipeline: &Option<FilterPipeline>,
|
||
element_size: u32,
|
||
) -> Result<Vec<Vec<u8>>, FormatError> {
|
||
#[cfg(feature = "parallel")]
|
||
{
|
||
if let Some(pl) = pipeline
|
||
&& chunks.len() > PARALLEL_COMPRESS_THRESHOLD
|
||
{
|
||
use rayon::prelude::*;
|
||
return chunks
|
||
.par_iter()
|
||
.map(|(_offsets, chunk_bytes)| compress_chunk(chunk_bytes, pl, element_size))
|
||
.collect();
|
||
}
|
||
}
|
||
|
||
// Sequential fallback
|
||
chunks
|
||
.iter()
|
||
.map(|(_offsets, chunk_bytes)| {
|
||
if let Some(pl) = pipeline {
|
||
compress_chunk(chunk_bytes, pl, element_size)
|
||
} else {
|
||
Ok(chunk_bytes.clone())
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Build the complete chunked dataset blob (chunk data + index) and return
|
||
/// layout/pipeline messages. `base_address` is where the blob will be placed in the file.
|
||
/// Serialize a v4 single chunk layout message (public for OH size estimation).
|
||
pub fn serialize_v4_single_chunk_pub(
|
||
chunk_dims: &[u32],
|
||
chunk_address: u64,
|
||
filtered_size: Option<u64>,
|
||
filter_mask: Option<u32>,
|
||
offset_size: u8,
|
||
element_size: u32,
|
||
) -> Vec<u8> {
|
||
serialize_v4_single_chunk(
|
||
chunk_dims,
|
||
chunk_address,
|
||
filtered_size,
|
||
filter_mask,
|
||
offset_size,
|
||
element_size,
|
||
)
|
||
}
|
||
|
||
/// Serialize a v4 single chunk layout message.
|
||
fn serialize_v4_single_chunk(
|
||
chunk_dims: &[u32],
|
||
chunk_address: u64,
|
||
filtered_size: Option<u64>,
|
||
filter_mask: Option<u32>,
|
||
offset_size: u8,
|
||
element_size: u32,
|
||
) -> Vec<u8> {
|
||
let mut buf = Vec::new();
|
||
buf.push(4); // version
|
||
buf.push(2); // class = chunked
|
||
|
||
// flags: bit 0 = unknown meaning in some files, bit 1 = filters for single chunk
|
||
let flags: u8 = if filtered_size.is_some() { 0x02 } else { 0x00 };
|
||
buf.push(flags);
|
||
|
||
// dimensionality = rank + 1 (chunk dims + element size dim)
|
||
let ndims = chunk_dims.len() as u8 + 1;
|
||
buf.push(ndims);
|
||
|
||
// dim_size_encoded_length: how many bytes per dimension
|
||
// We need to figure out the minimum encoding width
|
||
let max_dim = chunk_dims
|
||
.iter()
|
||
.map(|&d| d as u64)
|
||
.chain(core::iter::once(element_size as u64))
|
||
.max()
|
||
.unwrap_or(1);
|
||
let dim_encoded_len: u8 = if max_dim <= 0xFF {
|
||
1
|
||
} else if max_dim <= 0xFFFF {
|
||
2
|
||
} else {
|
||
4
|
||
};
|
||
buf.push(dim_encoded_len);
|
||
|
||
// dimension sizes (chunk dims + element size)
|
||
for &d in chunk_dims {
|
||
match dim_encoded_len {
|
||
1 => buf.push(d as u8),
|
||
2 => buf.extend_from_slice(&(d as u16).to_le_bytes()),
|
||
4 => buf.extend_from_slice(&d.to_le_bytes()),
|
||
_ => {}
|
||
}
|
||
}
|
||
// Element size dimension
|
||
match dim_encoded_len {
|
||
1 => buf.push(element_size as u8),
|
||
2 => buf.extend_from_slice(&(element_size as u16).to_le_bytes()),
|
||
4 => buf.extend_from_slice(&element_size.to_le_bytes()),
|
||
_ => {}
|
||
}
|
||
|
||
// chunk index type = 1 (single chunk)
|
||
buf.push(1);
|
||
|
||
// Index-specific fields
|
||
if let (Some(fs), Some(fm)) = (filtered_size, filter_mask) {
|
||
// filtered_size (length_size bytes)
|
||
buf.extend_from_slice(&fs.to_le_bytes()); // 8 bytes for length_size=8
|
||
buf.extend_from_slice(&fm.to_le_bytes()); // 4 bytes
|
||
}
|
||
|
||
// chunk address
|
||
match offset_size {
|
||
4 => buf.extend_from_slice(&(chunk_address as u32).to_le_bytes()),
|
||
8 => buf.extend_from_slice(&chunk_address.to_le_bytes()),
|
||
_ => {}
|
||
}
|
||
|
||
buf
|
||
}
|
||
|
||
/// Serialize a v4 Fixed Array layout message.
|
||
fn serialize_v4_fixed_array(
|
||
chunk_dims: &[u32],
|
||
fixed_array_address: u64,
|
||
offset_size: u8,
|
||
element_size: u32,
|
||
max_bits: u8,
|
||
) -> Vec<u8> {
|
||
let mut buf = Vec::new();
|
||
buf.push(4); // version
|
||
buf.push(2); // class = chunked
|
||
|
||
let flags: u8 = 0x00;
|
||
buf.push(flags);
|
||
|
||
let ndims = chunk_dims.len() as u8 + 1;
|
||
buf.push(ndims);
|
||
|
||
let max_dim = chunk_dims
|
||
.iter()
|
||
.map(|&d| d as u64)
|
||
.chain(core::iter::once(element_size as u64))
|
||
.max()
|
||
.unwrap_or(1);
|
||
let dim_encoded_len: u8 = if max_dim <= 0xFF {
|
||
1
|
||
} else if max_dim <= 0xFFFF {
|
||
2
|
||
} else {
|
||
4
|
||
};
|
||
buf.push(dim_encoded_len);
|
||
|
||
for &d in chunk_dims {
|
||
match dim_encoded_len {
|
||
1 => buf.push(d as u8),
|
||
2 => buf.extend_from_slice(&(d as u16).to_le_bytes()),
|
||
4 => buf.extend_from_slice(&d.to_le_bytes()),
|
||
_ => {}
|
||
}
|
||
}
|
||
match dim_encoded_len {
|
||
1 => buf.push(element_size as u8),
|
||
2 => buf.extend_from_slice(&(element_size as u16).to_le_bytes()),
|
||
4 => buf.extend_from_slice(&element_size.to_le_bytes()),
|
||
_ => {}
|
||
}
|
||
|
||
// chunk index type = 3 (Fixed Array)
|
||
buf.push(3);
|
||
|
||
// max_dblk_page_nelmts_bits — must match FAHD max_nelmts_bits
|
||
buf.push(max_bits);
|
||
|
||
// Fixed Array header address
|
||
match offset_size {
|
||
4 => buf.extend_from_slice(&(fixed_array_address as u32).to_le_bytes()),
|
||
8 => buf.extend_from_slice(&fixed_array_address.to_le_bytes()),
|
||
_ => {}
|
||
}
|
||
|
||
buf
|
||
}
|
||
|
||
/// Build a complete Fixed Array at a known absolute address.
|
||
pub fn build_fixed_array_at(
|
||
chunks: &[WrittenChunk],
|
||
offset_size: u8,
|
||
length_size: u8,
|
||
has_filters: bool,
|
||
fa_base_address: u64,
|
||
) -> Vec<u8> {
|
||
let os = offset_size as usize;
|
||
let num_elements = chunks.len();
|
||
|
||
// For filtered chunks, compute chunk_size encoding width.
|
||
// Must match the HDF5 C library's H5D_FARRAY_FILT_COMPUTE_CHUNK_SIZE_LEN macro:
|
||
// chunk_size_len = 1 + ((H5VM_log2_gen(chunk.size) + 8) / 8)
|
||
// where chunk.size is the unfiltered chunk size in bytes (product of all chunk dims).
|
||
let chunk_size_bytes: usize = if has_filters {
|
||
let max_raw = chunks.iter().map(|c| c.raw_size).max().unwrap_or(1);
|
||
let log2_val = if max_raw <= 1 {
|
||
0
|
||
} else {
|
||
63 - max_raw.leading_zeros()
|
||
};
|
||
let len = 1 + ((log2_val + 8) / 8) as usize;
|
||
len.min(8)
|
||
} else {
|
||
0
|
||
};
|
||
|
||
let elem_size = if has_filters {
|
||
os + chunk_size_bytes + 4
|
||
} else {
|
||
os
|
||
};
|
||
|
||
let client_id: u8 = if has_filters { 1 } else { 0 };
|
||
|
||
// FAHD total size
|
||
let nelmts_field_size = length_size as usize;
|
||
let fahd_total_size = 4 + 1 + 1 + 1 + 1 + nelmts_field_size + os + 4;
|
||
let fadb_address = fa_base_address + fahd_total_size as u64;
|
||
|
||
// Build FAHD
|
||
let mut fahd = Vec::with_capacity(fahd_total_size);
|
||
fahd.extend_from_slice(b"FAHD");
|
||
fahd.push(0); // version
|
||
fahd.push(client_id);
|
||
fahd.push(elem_size as u8);
|
||
|
||
// max_nelmts_bits: use 10 as default (page_size = 1024), matching h5py convention
|
||
let max_bits: u8 = 10;
|
||
fahd.push(max_bits);
|
||
|
||
match length_size {
|
||
4 => fahd.extend_from_slice(&(num_elements as u32).to_le_bytes()),
|
||
8 => fahd.extend_from_slice(&(num_elements as u64).to_le_bytes()),
|
||
_ => fahd.extend_from_slice(&(num_elements as u64).to_le_bytes()),
|
||
}
|
||
|
||
match offset_size {
|
||
4 => fahd.extend_from_slice(&(fadb_address as u32).to_le_bytes()),
|
||
8 => fahd.extend_from_slice(&fadb_address.to_le_bytes()),
|
||
_ => fahd.extend_from_slice(&fadb_address.to_le_bytes()),
|
||
}
|
||
|
||
// Checksum
|
||
let checksum = jenkins_lookup3(&fahd);
|
||
fahd.extend_from_slice(&checksum.to_le_bytes());
|
||
|
||
assert_eq!(fahd.len(), fahd_total_size);
|
||
|
||
// Build FADB
|
||
let mut fadb = Vec::new();
|
||
fadb.extend_from_slice(b"FADB");
|
||
fadb.push(0); // version
|
||
fadb.push(client_id);
|
||
|
||
// header address
|
||
match offset_size {
|
||
4 => fadb.extend_from_slice(&(fa_base_address as u32).to_le_bytes()),
|
||
8 => fadb.extend_from_slice(&fa_base_address.to_le_bytes()),
|
||
_ => fadb.extend_from_slice(&fa_base_address.to_le_bytes()),
|
||
}
|
||
|
||
// Element data
|
||
for chunk in chunks {
|
||
match offset_size {
|
||
4 => fadb.extend_from_slice(&(chunk.address as u32).to_le_bytes()),
|
||
8 => fadb.extend_from_slice(&chunk.address.to_le_bytes()),
|
||
_ => fadb.extend_from_slice(&chunk.address.to_le_bytes()),
|
||
}
|
||
if has_filters {
|
||
// Write compressed size using chunk_size_bytes (variable width)
|
||
let cs_bytes = chunk.compressed_size.to_le_bytes();
|
||
fadb.extend_from_slice(&cs_bytes[..chunk_size_bytes]);
|
||
fadb.extend_from_slice(&chunk.filter_mask.to_le_bytes());
|
||
}
|
||
}
|
||
|
||
// FADB checksum
|
||
let fadb_checksum = jenkins_lookup3(&fadb);
|
||
fadb.extend_from_slice(&fadb_checksum.to_le_bytes());
|
||
|
||
let mut combined = fahd;
|
||
combined.extend_from_slice(&fadb);
|
||
combined
|
||
}
|
||
|
||
/// Compressed chunks ready to be laid out at any file address.
|
||
///
|
||
/// Created by [`precompress_chunks`] and consumed by
|
||
/// [`build_chunked_data_from_precompressed`]. Caching this between the two
|
||
/// writer passes eliminates the double-compression that the two-pass layout
|
||
/// algorithm previously performed.
|
||
pub struct PrecompressedChunks {
|
||
/// Per-chunk: (raw_size_bytes, compressed_bytes).
|
||
pub chunks: Vec<(u64, Vec<u8>)>,
|
||
pub has_filters: bool,
|
||
pub element_size: usize,
|
||
pub shape: Vec<u64>,
|
||
pub chunk_dims: Vec<u64>,
|
||
pub pipeline_message: Option<Vec<u8>>,
|
||
}
|
||
|
||
/// Compress all chunks of a dataset without laying them out at a file address.
|
||
///
|
||
/// Call this once per dataset in Pass 1, cache the result, then call
|
||
/// [`build_chunked_data_from_precompressed`] in both Pass 1 (dummy address
|
||
/// for sizing) and Pass 2 (real address) to avoid re-compressing.
|
||
pub fn precompress_chunks(
|
||
raw_data: &[u8],
|
||
shape: &[u64],
|
||
chunk_dims: &[u64],
|
||
element_size: usize,
|
||
options: &ChunkOptions,
|
||
) -> Result<PrecompressedChunks, FormatError> {
|
||
let pipeline = options.build_pipeline(element_size as u32);
|
||
let has_filters = pipeline.is_some();
|
||
let pipeline_message = pipeline.as_ref().map(|pl| pl.serialize());
|
||
|
||
let raw_chunks = split_into_chunks(raw_data, shape, chunk_dims, element_size);
|
||
let compressed = compress_all_chunks(&raw_chunks, &pipeline, element_size as u32)?;
|
||
|
||
let chunks = raw_chunks
|
||
.into_iter()
|
||
.zip(compressed)
|
||
.map(|((_offsets, raw_bytes), c)| (raw_bytes.len() as u64, c))
|
||
.collect();
|
||
|
||
Ok(PrecompressedChunks {
|
||
chunks,
|
||
has_filters,
|
||
element_size,
|
||
shape: shape.to_vec(),
|
||
chunk_dims: chunk_dims.to_vec(),
|
||
pipeline_message,
|
||
})
|
||
}
|
||
|
||
/// Lay out precompressed chunks at `base_address` and build index structures.
|
||
///
|
||
/// This is the address-dependent half of chunk writing. Call it in Pass 1
|
||
/// with a dummy address (to get the blob size), and again in Pass 2 with the
|
||
/// real address — both times reusing the same [`PrecompressedChunks`] so
|
||
/// compression happens only once.
|
||
pub fn build_chunked_data_from_precompressed(
|
||
pre: &PrecompressedChunks,
|
||
base_address: u64,
|
||
maxshape: Option<&[u64]>,
|
||
) -> ChunkedDataResult {
|
||
let offset_size: u8 = 8;
|
||
let length_size: u8 = 8;
|
||
let num_chunks = pre.chunks.len();
|
||
let element_size = pre.element_size;
|
||
|
||
let mut data_buf = Vec::new();
|
||
let mut written_chunks = Vec::with_capacity(num_chunks);
|
||
|
||
for (raw_size, compressed) in &pre.chunks {
|
||
let aligned_offset = align_to_cache_line(data_buf.len());
|
||
if aligned_offset > data_buf.len() {
|
||
data_buf.resize(aligned_offset, 0u8);
|
||
}
|
||
let address = base_address + data_buf.len() as u64;
|
||
let compressed_size = compressed.len() as u64;
|
||
data_buf.extend_from_slice(compressed);
|
||
written_chunks.push(WrittenChunk {
|
||
address,
|
||
compressed_size,
|
||
raw_size: *raw_size,
|
||
filter_mask: 0,
|
||
});
|
||
}
|
||
|
||
let chunk_dims_u32: Vec<u32> = pre.chunk_dims.iter().map(|&d| d as u32).collect();
|
||
let use_extensible = maxshape.is_some_and(|ms| ms.contains(&u64::MAX));
|
||
|
||
let aligned_idx = align_to_cache_line(data_buf.len());
|
||
if aligned_idx > data_buf.len() {
|
||
data_buf.resize(aligned_idx, 0u8);
|
||
}
|
||
|
||
let layout_message = if use_extensible {
|
||
let ea_address = base_address + data_buf.len() as u64;
|
||
let ea_bytes = ea_writer::build_extensible_array_at(
|
||
&written_chunks,
|
||
offset_size,
|
||
length_size,
|
||
pre.has_filters,
|
||
ea_address,
|
||
);
|
||
data_buf.extend_from_slice(&ea_bytes);
|
||
ea_writer::serialize_v4_extensible_array(
|
||
&chunk_dims_u32,
|
||
ea_address,
|
||
offset_size,
|
||
element_size as u32,
|
||
)
|
||
} else if num_chunks == 1 {
|
||
let chunk_addr = written_chunks[0].address;
|
||
let filtered_size = if pre.has_filters {
|
||
Some(written_chunks[0].compressed_size)
|
||
} else {
|
||
None
|
||
};
|
||
let filter_mask = if pre.has_filters { Some(0u32) } else { None };
|
||
serialize_v4_single_chunk(
|
||
&chunk_dims_u32,
|
||
chunk_addr,
|
||
filtered_size,
|
||
filter_mask,
|
||
offset_size,
|
||
element_size as u32,
|
||
)
|
||
} else {
|
||
let fa_address = base_address + data_buf.len() as u64;
|
||
let fa_bytes = build_fixed_array_at(
|
||
&written_chunks,
|
||
offset_size,
|
||
length_size,
|
||
pre.has_filters,
|
||
fa_address,
|
||
);
|
||
data_buf.extend_from_slice(&fa_bytes);
|
||
serialize_v4_fixed_array(
|
||
&chunk_dims_u32,
|
||
fa_address,
|
||
offset_size,
|
||
element_size as u32,
|
||
10, // max_nelmts_bits — matches h5py convention
|
||
)
|
||
};
|
||
|
||
ChunkedDataResult {
|
||
data_bytes: data_buf,
|
||
layout_message,
|
||
pipeline_message: pre.pipeline_message.clone(),
|
||
}
|
||
}
|
||
|
||
/// Build chunked data with absolute addresses.
|
||
/// If `maxshape` has unlimited dims, uses Extensible Array index.
|
||
pub fn build_chunked_data_at(
|
||
raw_data: &[u8],
|
||
shape: &[u64],
|
||
chunk_dims: &[u64],
|
||
element_size: usize,
|
||
options: &ChunkOptions,
|
||
base_address: u64,
|
||
) -> Result<ChunkedDataResult, FormatError> {
|
||
build_chunked_data_at_ext(
|
||
raw_data,
|
||
shape,
|
||
chunk_dims,
|
||
element_size,
|
||
options,
|
||
base_address,
|
||
None,
|
||
)
|
||
}
|
||
|
||
/// Build chunked data with absolute addresses and optional maxshape.
|
||
pub fn build_chunked_data_at_ext(
|
||
raw_data: &[u8],
|
||
shape: &[u64],
|
||
chunk_dims: &[u64],
|
||
element_size: usize,
|
||
options: &ChunkOptions,
|
||
base_address: u64,
|
||
maxshape: Option<&[u64]>,
|
||
) -> Result<ChunkedDataResult, FormatError> {
|
||
let pre = precompress_chunks(raw_data, shape, chunk_dims, element_size, options)?;
|
||
Ok(build_chunked_data_from_precompressed(
|
||
&pre,
|
||
base_address,
|
||
maxshape,
|
||
))
|
||
}
|
||
|
||
/// Write selected elements into an existing in-memory dataset buffer.
|
||
///
|
||
/// This performs a read-modify-write on the full buffer: elements matching
|
||
/// the selection are overwritten with the provided `new_data`. The buffer
|
||
/// must be a complete, uncompressed dataset of the given shape.
|
||
///
|
||
/// This is the in-memory equivalent of partial hyperslab writes. The caller
|
||
/// is responsible for re-chunking and re-compressing the buffer afterward.
|
||
pub fn write_selection_to_buffer(
|
||
buffer: &mut [u8],
|
||
dims: &[u64],
|
||
elem_size: usize,
|
||
selection: &crate::selection::Selection,
|
||
new_data: &[u8],
|
||
) {
|
||
use crate::selection::Selection;
|
||
|
||
match selection {
|
||
Selection::All => {
|
||
let len = buffer.len().min(new_data.len());
|
||
buffer[..len].copy_from_slice(&new_data[..len]);
|
||
}
|
||
Selection::None => {}
|
||
Selection::Hyperslab {
|
||
start,
|
||
stride,
|
||
count,
|
||
block,
|
||
} => {
|
||
let rank = dims.len();
|
||
let mut ds_strides = vec![1usize; rank];
|
||
for i in (0..rank.saturating_sub(1)).rev() {
|
||
ds_strides[i] = ds_strides[i + 1] * dims[i + 1] as usize;
|
||
}
|
||
|
||
let mut src_offset = 0usize;
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn write_hyperslab(
|
||
d: usize,
|
||
rank: usize,
|
||
start: &[u64],
|
||
stride: &[u64],
|
||
count: &[u64],
|
||
block: &[u64],
|
||
dims: &[u64],
|
||
ds_strides: &[usize],
|
||
elem_size: usize,
|
||
buffer: &mut [u8],
|
||
new_data: &[u8],
|
||
src_offset: &mut usize,
|
||
current_ds_offset: usize,
|
||
) {
|
||
if d == rank {
|
||
let dst = current_ds_offset * elem_size;
|
||
let src = *src_offset * elem_size;
|
||
if dst + elem_size <= buffer.len() && src + elem_size <= new_data.len() {
|
||
buffer[dst..dst + elem_size]
|
||
.copy_from_slice(&new_data[src..src + elem_size]);
|
||
}
|
||
*src_offset += 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] {
|
||
write_hyperslab(
|
||
d + 1,
|
||
rank,
|
||
start,
|
||
stride,
|
||
count,
|
||
block,
|
||
dims,
|
||
ds_strides,
|
||
elem_size,
|
||
buffer,
|
||
new_data,
|
||
src_offset,
|
||
current_ds_offset + coord as usize * ds_strides[d],
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
write_hyperslab(
|
||
0,
|
||
rank,
|
||
start,
|
||
stride,
|
||
count,
|
||
block,
|
||
dims,
|
||
&ds_strides,
|
||
elem_size,
|
||
buffer,
|
||
new_data,
|
||
&mut src_offset,
|
||
0,
|
||
);
|
||
}
|
||
Selection::Points(pts) => {
|
||
let rank = dims.len();
|
||
let mut ds_strides = vec![1usize; rank];
|
||
for i in (0..rank.saturating_sub(1)).rev() {
|
||
ds_strides[i] = ds_strides[i + 1] * dims[i + 1] as usize;
|
||
}
|
||
|
||
for (pi, pt) in pts.iter().enumerate() {
|
||
let flat: usize = pt
|
||
.iter()
|
||
.zip(ds_strides.iter())
|
||
.map(|(&p, &s)| p as usize * s)
|
||
.sum();
|
||
let dst = flat * elem_size;
|
||
let src = pi * elem_size;
|
||
if dst + elem_size <= buffer.len() && src + elem_size <= new_data.len() {
|
||
buffer[dst..dst + elem_size].copy_from_slice(&new_data[src..src + elem_size]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::chunked_read::read_chunked_data;
|
||
use crate::data_layout::DataLayout;
|
||
use crate::dataspace::{Dataspace, DataspaceType};
|
||
use crate::datatype::{Datatype, DatatypeByteOrder};
|
||
|
||
fn make_f64_type() -> Datatype {
|
||
Datatype::FloatingPoint {
|
||
size: 8,
|
||
byte_order: DatatypeByteOrder::LittleEndian,
|
||
bit_offset: 0,
|
||
bit_precision: 64,
|
||
exponent_location: 52,
|
||
exponent_size: 11,
|
||
mantissa_location: 0,
|
||
mantissa_size: 52,
|
||
exponent_bias: 1023,
|
||
}
|
||
}
|
||
|
||
fn f64_to_bytes(data: &[f64]) -> Vec<u8> {
|
||
let mut b = Vec::with_capacity(data.len() * 8);
|
||
for &v in data {
|
||
b.extend_from_slice(&v.to_le_bytes());
|
||
}
|
||
b
|
||
}
|
||
|
||
fn bytes_to_f64(data: &[u8]) -> Vec<f64> {
|
||
data.chunks(8)
|
||
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
|
||
.collect()
|
||
}
|
||
|
||
/// Helper: build a chunked file blob and read it back using read_chunked_data
|
||
fn roundtrip_chunked(
|
||
values: &[f64],
|
||
shape: &[u64],
|
||
chunk_dims: &[u64],
|
||
options: &ChunkOptions,
|
||
) -> Vec<f64> {
|
||
let raw = f64_to_bytes(values);
|
||
let base_address = 0x1000u64;
|
||
let result =
|
||
build_chunked_data_at(&raw, shape, chunk_dims, 8, options, base_address).unwrap();
|
||
|
||
// Build a fake file buffer
|
||
let file_size = base_address as usize + result.data_bytes.len();
|
||
let mut file_data = vec![0u8; file_size];
|
||
file_data[base_address as usize..].copy_from_slice(&result.data_bytes);
|
||
|
||
// Parse layout
|
||
let layout = DataLayout::parse(&result.layout_message, 8, 8).unwrap();
|
||
let dataspace = Dataspace {
|
||
space_type: DataspaceType::Simple,
|
||
rank: shape.len() as u8,
|
||
dimensions: shape.to_vec(),
|
||
max_dimensions: None,
|
||
};
|
||
let datatype = make_f64_type();
|
||
|
||
// Parse pipeline if present
|
||
let pipeline = result
|
||
.pipeline_message
|
||
.as_ref()
|
||
.map(|pm| crate::filter_pipeline::FilterPipeline::parse(pm).unwrap());
|
||
|
||
let output = read_chunked_data(
|
||
&file_data,
|
||
&layout,
|
||
&dataspace,
|
||
&datatype,
|
||
pipeline.as_ref(),
|
||
8,
|
||
8,
|
||
)
|
||
.unwrap();
|
||
|
||
bytes_to_f64(&output)
|
||
}
|
||
|
||
#[test]
|
||
fn split_1d_single_chunk() {
|
||
let data = f64_to_bytes(&[1.0, 2.0, 3.0]);
|
||
let result = split_into_chunks(&data, &[3], &[3], 8);
|
||
assert_eq!(result.len(), 1);
|
||
assert_eq!(result[0].0, vec![0]);
|
||
assert_eq!(bytes_to_f64(&result[0].1), vec![1.0, 2.0, 3.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn split_1d_multiple_chunks() {
|
||
let values: Vec<f64> = (0..10).map(|i| i as f64).collect();
|
||
let data = f64_to_bytes(&values);
|
||
let result = split_into_chunks(&data, &[10], &[4], 8);
|
||
assert_eq!(result.len(), 3); // ceil(10/4) = 3
|
||
assert_eq!(result[0].0, vec![0]);
|
||
assert_eq!(result[1].0, vec![4]);
|
||
assert_eq!(result[2].0, vec![8]);
|
||
assert_eq!(bytes_to_f64(&result[0].1), vec![0.0, 1.0, 2.0, 3.0]);
|
||
assert_eq!(bytes_to_f64(&result[1].1), vec![4.0, 5.0, 6.0, 7.0]);
|
||
// Last chunk: 2 valid + 2 padding zeros
|
||
assert_eq!(bytes_to_f64(&result[2].1), vec![8.0, 9.0, 0.0, 0.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn split_2d_chunks() {
|
||
// 4x4 dataset, 2x2 chunks -> 4 chunks
|
||
let values: Vec<f64> = (0..16).map(|i| i as f64).collect();
|
||
let data = f64_to_bytes(&values);
|
||
let result = split_into_chunks(&data, &[4, 4], &[2, 2], 8);
|
||
assert_eq!(result.len(), 4);
|
||
assert_eq!(result[0].0, vec![0, 0]);
|
||
assert_eq!(result[1].0, vec![0, 2]);
|
||
assert_eq!(result[2].0, vec![2, 0]);
|
||
assert_eq!(result[3].0, vec![2, 2]);
|
||
// chunk (0,0): elements [0,1,4,5]
|
||
assert_eq!(bytes_to_f64(&result[0].1), vec![0.0, 1.0, 4.0, 5.0]);
|
||
// chunk (0,2): elements [2,3,6,7]
|
||
assert_eq!(bytes_to_f64(&result[1].1), vec![2.0, 3.0, 6.0, 7.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn roundtrip_1d_single_chunk_no_compression() {
|
||
let values: Vec<f64> = (0..10).map(|i| i as f64).collect();
|
||
let options = ChunkOptions {
|
||
chunk_dims: Some(vec![10]),
|
||
..Default::default()
|
||
};
|
||
let result = roundtrip_chunked(&values, &[10], &[10], &options);
|
||
assert_eq!(result, values);
|
||
}
|
||
|
||
#[cfg(feature = "deflate")]
|
||
#[test]
|
||
fn roundtrip_1d_single_chunk_deflate() {
|
||
let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||
let options = ChunkOptions {
|
||
chunk_dims: Some(vec![100]),
|
||
deflate_level: Some(6),
|
||
..Default::default()
|
||
};
|
||
let result = roundtrip_chunked(&values, &[100], &[100], &options);
|
||
assert_eq!(result, values);
|
||
}
|
||
|
||
#[test]
|
||
fn roundtrip_1d_multi_chunk_no_compression() {
|
||
let values: Vec<f64> = (0..20).map(|i| i as f64).collect();
|
||
let options = ChunkOptions {
|
||
chunk_dims: Some(vec![8]),
|
||
..Default::default()
|
||
};
|
||
let result = roundtrip_chunked(&values, &[20], &[8], &options);
|
||
assert_eq!(result, values);
|
||
}
|
||
|
||
#[cfg(feature = "deflate")]
|
||
#[test]
|
||
fn roundtrip_1d_multi_chunk_deflate() {
|
||
let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||
let options = ChunkOptions {
|
||
chunk_dims: Some(vec![20]),
|
||
deflate_level: Some(6),
|
||
..Default::default()
|
||
};
|
||
let result = roundtrip_chunked(&values, &[100], &[20], &options);
|
||
assert_eq!(result, values);
|
||
}
|
||
|
||
#[cfg(feature = "deflate")]
|
||
#[test]
|
||
fn roundtrip_1d_shuffle_deflate() {
|
||
let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||
let options = ChunkOptions {
|
||
chunk_dims: Some(vec![50]),
|
||
deflate_level: Some(6),
|
||
shuffle: true,
|
||
..Default::default()
|
||
};
|
||
let result = roundtrip_chunked(&values, &[100], &[50], &options);
|
||
assert_eq!(result, values);
|
||
}
|
||
|
||
#[test]
|
||
fn roundtrip_2d_chunks() {
|
||
// 6x4 dataset, 3x2 chunks
|
||
let values: Vec<f64> = (0..24).map(|i| i as f64).collect();
|
||
let options = ChunkOptions {
|
||
chunk_dims: Some(vec![3, 2]),
|
||
..Default::default()
|
||
};
|
||
let result = roundtrip_chunked(&values, &[6, 4], &[3, 2], &options);
|
||
assert_eq!(result, values);
|
||
}
|
||
|
||
#[test]
|
||
fn align_chunk_offset_values() {
|
||
use super::CACHE_LINE_SIZE;
|
||
use super::align_chunk_offset;
|
||
let cl = CACHE_LINE_SIZE as u64;
|
||
assert_eq!(align_chunk_offset(0), 0);
|
||
assert_eq!(align_chunk_offset(1), cl);
|
||
assert_eq!(align_chunk_offset(cl), cl);
|
||
assert_eq!(align_chunk_offset(cl + 1), cl * 2);
|
||
assert_eq!(align_chunk_offset(cl * 10), cl * 10);
|
||
}
|
||
|
||
#[test]
|
||
fn chunk_addresses_are_cache_aligned() {
|
||
use super::align_chunk_offset;
|
||
let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||
let raw = f64_to_bytes(&values);
|
||
let base_address = 0x1000u64;
|
||
// Ensure base is aligned for this test
|
||
let base_address = align_chunk_offset(base_address);
|
||
let options = ChunkOptions {
|
||
chunk_dims: Some(vec![20]),
|
||
..Default::default()
|
||
};
|
||
let result = build_chunked_data_at(&raw, &[100], &[20], 8, &options, base_address).unwrap();
|
||
|
||
// Parse layout to get chunk addresses (via roundtrip read)
|
||
let file_size = base_address as usize + result.data_bytes.len();
|
||
let mut file_data = vec![0u8; file_size];
|
||
file_data[base_address as usize..].copy_from_slice(&result.data_bytes);
|
||
|
||
let layout = DataLayout::parse(&result.layout_message, 8, 8).unwrap();
|
||
let dataspace = Dataspace {
|
||
space_type: DataspaceType::Simple,
|
||
rank: 1,
|
||
dimensions: vec![100],
|
||
max_dimensions: None,
|
||
};
|
||
let datatype = make_f64_type();
|
||
|
||
// Verify data roundtrips correctly
|
||
let output =
|
||
read_chunked_data(&file_data, &layout, &dataspace, &datatype, None, 8, 8).unwrap();
|
||
assert_eq!(bytes_to_f64(&output), values);
|
||
}
|
||
|
||
#[test]
|
||
fn chunk_options_auto_dims() {
|
||
let options = ChunkOptions {
|
||
chunk_dims: None,
|
||
deflate_level: Some(6),
|
||
..Default::default()
|
||
};
|
||
let dims = options.resolve_chunk_dims(&[100, 50]);
|
||
assert_eq!(dims, vec![100, 50]);
|
||
}
|
||
|
||
#[test]
|
||
fn chunk_options_pipeline_deflate() {
|
||
// Auto-shuffle is applied before compression by default (matches h5py).
|
||
let options = ChunkOptions {
|
||
deflate_level: Some(6),
|
||
..Default::default()
|
||
};
|
||
let pl = options.build_pipeline(8).unwrap();
|
||
assert_eq!(pl.filters.len(), 2);
|
||
assert_eq!(pl.filters[0].filter_id, FILTER_SHUFFLE);
|
||
assert_eq!(pl.filters[1].filter_id, FILTER_DEFLATE);
|
||
}
|
||
|
||
#[test]
|
||
fn chunk_options_pipeline_deflate_no_shuffle() {
|
||
// Users can opt out of auto-shuffle with no_shuffle = true.
|
||
let options = ChunkOptions {
|
||
deflate_level: Some(6),
|
||
no_shuffle: true,
|
||
..Default::default()
|
||
};
|
||
let pl = options.build_pipeline(8).unwrap();
|
||
assert_eq!(pl.filters.len(), 1);
|
||
assert_eq!(pl.filters[0].filter_id, FILTER_DEFLATE);
|
||
}
|
||
|
||
#[test]
|
||
fn chunk_options_pipeline_lz4() {
|
||
// Auto-shuffle before LZ4.
|
||
let options = ChunkOptions {
|
||
lz4: true,
|
||
..Default::default()
|
||
};
|
||
let pl = options.build_pipeline(8).unwrap();
|
||
assert_eq!(pl.filters.len(), 2);
|
||
assert_eq!(pl.filters[0].filter_id, FILTER_SHUFFLE);
|
||
assert_eq!(pl.filters[1].filter_id, FILTER_LZ4);
|
||
}
|
||
|
||
#[test]
|
||
fn chunk_options_pipeline_zstd() {
|
||
// Auto-shuffle before Zstd.
|
||
let options = ChunkOptions {
|
||
zstd_level: Some(3),
|
||
..Default::default()
|
||
};
|
||
let pl = options.build_pipeline(8).unwrap();
|
||
assert_eq!(pl.filters.len(), 2);
|
||
assert_eq!(pl.filters[0].filter_id, FILTER_SHUFFLE);
|
||
assert_eq!(pl.filters[1].filter_id, FILTER_ZSTD);
|
||
assert_eq!(pl.filters[1].client_data, vec![3]);
|
||
}
|
||
|
||
#[test]
|
||
fn chunk_options_zstd_priority_over_deflate() {
|
||
let options = ChunkOptions {
|
||
deflate_level: Some(6),
|
||
zstd_level: Some(3),
|
||
..Default::default()
|
||
};
|
||
let pl = options.build_pipeline(8).unwrap();
|
||
// shuffle + zstd (deflate is ignored when zstd wins priority)
|
||
assert_eq!(pl.filters.len(), 2);
|
||
assert_eq!(pl.filters[0].filter_id, FILTER_SHUFFLE);
|
||
assert_eq!(pl.filters[1].filter_id, FILTER_ZSTD);
|
||
}
|
||
|
||
#[test]
|
||
fn chunk_options_pipeline_shuffle_deflate_fletcher32() {
|
||
let options = ChunkOptions {
|
||
deflate_level: Some(6),
|
||
shuffle: true,
|
||
fletcher32: true,
|
||
..Default::default()
|
||
};
|
||
let pl = options.build_pipeline(8).unwrap();
|
||
assert_eq!(pl.filters.len(), 3);
|
||
assert_eq!(pl.filters[0].filter_id, FILTER_SHUFFLE);
|
||
assert_eq!(pl.filters[1].filter_id, FILTER_DEFLATE);
|
||
assert_eq!(pl.filters[2].filter_id, FILTER_FLETCHER32);
|
||
}
|
||
|
||
#[test]
|
||
fn serialize_v4_single_chunk_no_filters_roundtrip() {
|
||
let msg = serialize_v4_single_chunk(&[20], 0x1000, None, None, 8, 8);
|
||
let layout = DataLayout::parse(&msg, 8, 8).unwrap();
|
||
match layout {
|
||
DataLayout::Chunked {
|
||
chunk_dimensions,
|
||
btree_address,
|
||
version,
|
||
chunk_index_type,
|
||
single_chunk_filtered_size,
|
||
single_chunk_filter_mask,
|
||
} => {
|
||
assert_eq!(version, 4);
|
||
assert_eq!(chunk_index_type, Some(1));
|
||
assert_eq!(chunk_dimensions, vec![20, 8]);
|
||
assert_eq!(btree_address, Some(0x1000));
|
||
assert_eq!(single_chunk_filtered_size, None);
|
||
assert_eq!(single_chunk_filter_mask, None);
|
||
}
|
||
_ => panic!("expected chunked layout"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn serialize_v4_single_chunk_with_filters_roundtrip() {
|
||
let msg = serialize_v4_single_chunk(&[100], 0x2000, Some(500), Some(0), 8, 8);
|
||
let layout = DataLayout::parse(&msg, 8, 8).unwrap();
|
||
match layout {
|
||
DataLayout::Chunked {
|
||
btree_address,
|
||
single_chunk_filtered_size,
|
||
single_chunk_filter_mask,
|
||
..
|
||
} => {
|
||
assert_eq!(btree_address, Some(0x2000));
|
||
assert_eq!(single_chunk_filtered_size, Some(500));
|
||
assert_eq!(single_chunk_filter_mask, Some(0));
|
||
}
|
||
_ => panic!("expected chunked layout"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn serialize_v4_fixed_array_roundtrip() {
|
||
let msg = serialize_v4_fixed_array(&[20], 0x3000, 8, 8, 4);
|
||
let layout = DataLayout::parse(&msg, 8, 8).unwrap();
|
||
match layout {
|
||
DataLayout::Chunked {
|
||
version,
|
||
chunk_index_type,
|
||
btree_address,
|
||
chunk_dimensions,
|
||
..
|
||
} => {
|
||
assert_eq!(version, 4);
|
||
assert_eq!(chunk_index_type, Some(3));
|
||
assert_eq!(btree_address, Some(0x3000));
|
||
assert_eq!(chunk_dimensions, vec![20, 8]);
|
||
}
|
||
_ => panic!("expected chunked layout"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn build_fixed_array_valid_structure() {
|
||
let chunks = vec![
|
||
WrittenChunk {
|
||
address: 0x1000,
|
||
compressed_size: 160,
|
||
raw_size: 160,
|
||
filter_mask: 0,
|
||
},
|
||
WrittenChunk {
|
||
address: 0x10A0,
|
||
compressed_size: 160,
|
||
raw_size: 160,
|
||
filter_mask: 0,
|
||
},
|
||
];
|
||
let fa = build_fixed_array_at(&chunks, 8, 8, false, 0x2000);
|
||
// Should start with FAHD
|
||
assert_eq!(&fa[0..4], b"FAHD");
|
||
// FAHD size = 4+1+1+1+1+8+8+4 = 28
|
||
// FADB starts at offset 28
|
||
assert_eq!(&fa[28..32], b"FADB");
|
||
}
|
||
|
||
// ---- Extensible Array tests ----
|
||
|
||
#[test]
|
||
fn serialize_v4_extensible_array_roundtrip() {
|
||
let msg = ea_writer::serialize_v4_extensible_array(&[10], 0x4000, 8, 8);
|
||
let layout = DataLayout::parse(&msg, 8, 8).unwrap();
|
||
match layout {
|
||
DataLayout::Chunked {
|
||
version,
|
||
chunk_index_type,
|
||
btree_address,
|
||
chunk_dimensions,
|
||
..
|
||
} => {
|
||
assert_eq!(version, 4);
|
||
assert_eq!(chunk_index_type, Some(4));
|
||
assert_eq!(btree_address, Some(0x4000));
|
||
assert_eq!(chunk_dimensions, vec![10, 8]);
|
||
}
|
||
_ => panic!("expected chunked layout"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn build_extensible_array_valid_structure() {
|
||
let chunks = vec![
|
||
WrittenChunk {
|
||
address: 0x1000,
|
||
compressed_size: 80,
|
||
raw_size: 80,
|
||
filter_mask: 0,
|
||
},
|
||
WrittenChunk {
|
||
address: 0x1050,
|
||
compressed_size: 80,
|
||
raw_size: 80,
|
||
filter_mask: 0,
|
||
},
|
||
];
|
||
let ea = ea_writer::build_extensible_array_at(&chunks, 8, 8, false, 0x2000);
|
||
assert_eq!(&ea[0..4], b"EAHD");
|
||
// Find EAIB after EAHD: 12 fixed + 6*8 stats + 8 addr + 4 checksum = 72
|
||
let aehd_size = 4 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 6 * 8 + 8 + 4;
|
||
assert_eq!(&ea[aehd_size..aehd_size + 4], b"EAIB");
|
||
}
|
||
|
||
/// Helper: roundtrip with EA (maxshape)
|
||
fn roundtrip_ea(
|
||
values: &[f64],
|
||
shape: &[u64],
|
||
chunk_dims: &[u64],
|
||
maxshape: &[u64],
|
||
) -> Vec<f64> {
|
||
let raw = f64_to_bytes(values);
|
||
let base_address = 0x1000u64;
|
||
let options = ChunkOptions {
|
||
chunk_dims: Some(chunk_dims.to_vec()),
|
||
..Default::default()
|
||
};
|
||
let result = build_chunked_data_at_ext(
|
||
&raw,
|
||
shape,
|
||
chunk_dims,
|
||
8,
|
||
&options,
|
||
base_address,
|
||
Some(maxshape),
|
||
)
|
||
.unwrap();
|
||
|
||
let file_size = base_address as usize + result.data_bytes.len();
|
||
let mut file_data = vec![0u8; file_size];
|
||
file_data[base_address as usize..].copy_from_slice(&result.data_bytes);
|
||
|
||
let layout = DataLayout::parse(&result.layout_message, 8, 8).unwrap();
|
||
// Verify it uses EA index
|
||
match &layout {
|
||
DataLayout::Chunked {
|
||
chunk_index_type, ..
|
||
} => {
|
||
assert_eq!(*chunk_index_type, Some(4), "expected EA index type");
|
||
}
|
||
_ => panic!("expected chunked layout"),
|
||
}
|
||
|
||
let dataspace = Dataspace {
|
||
space_type: DataspaceType::Simple,
|
||
rank: shape.len() as u8,
|
||
dimensions: shape.to_vec(),
|
||
max_dimensions: Some(maxshape.to_vec()),
|
||
};
|
||
let datatype = make_f64_type();
|
||
|
||
let output =
|
||
read_chunked_data(&file_data, &layout, &dataspace, &datatype, None, 8, 8).unwrap();
|
||
|
||
bytes_to_f64(&output)
|
||
}
|
||
|
||
#[test]
|
||
fn ea_roundtrip_1d_inline_only() {
|
||
let values: Vec<f64> = (0..10).map(|i| i as f64).collect();
|
||
let result = roundtrip_ea(&values, &[10], &[10], &[u64::MAX]);
|
||
assert_eq!(result, values);
|
||
}
|
||
|
||
#[test]
|
||
fn ea_roundtrip_1d_multi_chunks() {
|
||
let values: Vec<f64> = (0..20).map(|i| i as f64).collect();
|
||
let result = roundtrip_ea(&values, &[20], &[5], &[u64::MAX]);
|
||
assert_eq!(result, values);
|
||
}
|
||
|
||
#[test]
|
||
fn ea_roundtrip_1d_many_chunks() {
|
||
let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||
let result = roundtrip_ea(&values, &[100], &[10], &[u64::MAX]);
|
||
assert_eq!(result, values);
|
||
}
|
||
|
||
// ---- h5py round-trip tests for chunked writes ----
|
||
|
||
#[cfg(feature = "std")]
|
||
fn h5py_available() -> bool {
|
||
std::process::Command::new("python3")
|
||
.args(["-c", "import h5py"])
|
||
.output()
|
||
.map(|o| o.status.success())
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
#[cfg(feature = "std")]
|
||
fn h5py_run(script: &str) -> String {
|
||
if !h5py_available() {
|
||
panic!("h5py not installed — skipping interop test");
|
||
}
|
||
let o = std::process::Command::new("python3")
|
||
.args(["-c", script])
|
||
.output()
|
||
.expect("python3");
|
||
if !o.status.success() {
|
||
panic!("h5py: {}", String::from_utf8_lossy(&o.stderr));
|
||
}
|
||
String::from_utf8(o.stdout).unwrap().trim().to_string()
|
||
}
|
||
|
||
#[cfg(feature = "std")]
|
||
#[test]
|
||
#[ignore = "requires Python h5py module"]
|
||
fn h5py_reads_multiple_chunked_datasets() {
|
||
use crate::file_writer::FileWriter;
|
||
let mut fw = FileWriter::new();
|
||
let data1: Vec<f64> = (0..50).map(|i| i as f64).collect();
|
||
let data2: Vec<f64> = (0..30).map(|i| (i * 10) as f64).collect();
|
||
fw.create_dataset("a")
|
||
.with_f64_data(&data1)
|
||
.with_shape(&[50])
|
||
.with_chunks(&[25]);
|
||
fw.create_dataset("b")
|
||
.with_f64_data(&data2)
|
||
.with_shape(&[30])
|
||
.with_chunks(&[10]);
|
||
let bytes = fw.finish().unwrap();
|
||
let path = std::env::temp_dir().join("clawhdf5_chunked_multi.h5");
|
||
std::fs::write(&path, &bytes).unwrap();
|
||
let script = format!(
|
||
"import h5py,json; f=h5py.File('{}','r'); print(json.dumps({{'a':f['a'][:].tolist(),'b':f['b'][:].tolist()}}))",
|
||
path.display()
|
||
);
|
||
let v: serde_json::Value = serde_json::from_str(&h5py_run(&script)).unwrap();
|
||
let va: Vec<f64> = serde_json::from_value(v["a"].clone()).unwrap();
|
||
let vb: Vec<f64> = serde_json::from_value(v["b"].clone()).unwrap();
|
||
assert_eq!(va, data1);
|
||
assert_eq!(vb, data2);
|
||
}
|
||
|
||
#[cfg(feature = "std")]
|
||
#[test]
|
||
#[ignore = "requires Python h5py module"]
|
||
fn h5py_reads_chunked_with_attrs() {
|
||
use crate::file_writer::{AttrValue, FileWriter};
|
||
let mut fw = FileWriter::new();
|
||
let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
|
||
fw.create_dataset("data")
|
||
.with_f64_data(&data)
|
||
.with_shape(&[50])
|
||
.with_chunks(&[25])
|
||
.set_attr("units", AttrValue::String("meters".to_string()));
|
||
let bytes = fw.finish().unwrap();
|
||
let path = std::env::temp_dir().join("clawhdf5_chunked_attrs.h5");
|
||
std::fs::write(&path, &bytes).unwrap();
|
||
let script = format!(
|
||
"import h5py,json; f=h5py.File('{}','r'); d=f['data']; print(json.dumps({{'values':d[:].tolist(),'units':d.attrs['units'].decode() if isinstance(d.attrs['units'],bytes) else str(d.attrs['units'])}}))",
|
||
path.display()
|
||
);
|
||
let v: serde_json::Value = serde_json::from_str(&h5py_run(&script)).unwrap();
|
||
let values: Vec<f64> = serde_json::from_value(v["values"].clone()).unwrap();
|
||
assert_eq!(values, data);
|
||
assert_eq!(v["units"], serde_json::json!("meters"));
|
||
}
|
||
|
||
// --- LZ4 chunked roundtrip tests ---
|
||
|
||
#[cfg(feature = "lz4")]
|
||
#[test]
|
||
fn roundtrip_1d_single_chunk_lz4() {
|
||
let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||
let options = ChunkOptions {
|
||
chunk_dims: Some(vec![100]),
|
||
lz4: true,
|
||
..Default::default()
|
||
};
|
||
let result = roundtrip_chunked(&values, &[100], &[100], &options);
|
||
assert_eq!(result, values);
|
||
}
|
||
|
||
#[cfg(feature = "lz4")]
|
||
#[test]
|
||
fn roundtrip_1d_multi_chunk_lz4() {
|
||
let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||
let options = ChunkOptions {
|
||
chunk_dims: Some(vec![20]),
|
||
lz4: true,
|
||
..Default::default()
|
||
};
|
||
let result = roundtrip_chunked(&values, &[100], &[20], &options);
|
||
assert_eq!(result, values);
|
||
}
|
||
|
||
// --- Zstd chunked roundtrip tests ---
|
||
|
||
#[cfg(feature = "zstd")]
|
||
#[test]
|
||
fn roundtrip_1d_single_chunk_zstd() {
|
||
let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||
let options = ChunkOptions {
|
||
chunk_dims: Some(vec![100]),
|
||
zstd_level: Some(3),
|
||
..Default::default()
|
||
};
|
||
let result = roundtrip_chunked(&values, &[100], &[100], &options);
|
||
assert_eq!(result, values);
|
||
}
|
||
|
||
#[cfg(feature = "zstd")]
|
||
#[test]
|
||
fn roundtrip_1d_multi_chunk_zstd() {
|
||
let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||
let options = ChunkOptions {
|
||
chunk_dims: Some(vec![20]),
|
||
zstd_level: Some(1),
|
||
..Default::default()
|
||
};
|
||
let result = roundtrip_chunked(&values, &[100], &[20], &options);
|
||
assert_eq!(result, values);
|
||
}
|
||
|
||
#[cfg(feature = "zstd")]
|
||
#[test]
|
||
fn roundtrip_1d_shuffle_zstd() {
|
||
let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||
let options = ChunkOptions {
|
||
chunk_dims: Some(vec![50]),
|
||
zstd_level: Some(3),
|
||
shuffle: true,
|
||
..Default::default()
|
||
};
|
||
let result = roundtrip_chunked(&values, &[100], &[50], &options);
|
||
assert_eq!(result, values);
|
||
}
|
||
}
|