feat: implement filter codecs, format write extensions, and MPI-IO VOL

All three SDD plans fully wired and committed to main:

Filter Codecs (FC):
- FC-1: Implement float E-scale in scaleoffset_decompress (value = minval +
  code * 2^E, negative exponents via cast to i32); add two round-trip tests.
- FC-2: filters_szip.rs — feature-gated SZIP decode via libaec FFI; SZIP
  dispatch arm added to decompress_chunk.
- FC-3: libaec-sys workspace crate with pkg-config probe and aec_buffer_decode
  FFI binding; added to workspace members.

Format Write Extensions (FWE):
- FWE-1: GroupBuilder::add_external_link() API; wired through FinishedGroup
  → GrpFlat → file_writer pass 1/2/3 (OH size, layout cursor, final write);
  external_link_write_roundtrip test.
- FWE-2: data_layout_write.rs — serialize_vds_mappings with length_size param
  and version 0/1 (external vs same-file) selection; declared as pub mod.
- FWE-3: with_virtual_sources empty-mapping guard (Important #9) — empty vec
  is silently ignored; vds_empty_mapping_list test updated to assert non-VDS
  layout results.

MPI-IO VOL Backend (MPI):
- MPI-1/2/3: mpi_vol.rs — MpiVol implementing VirtualObjectLayer; root-read
  + broadcast collective read; gather + root-write collective write; feature-
  gated mpi-io feature; wired into clawhdf5-io lib.rs.
- MPI-4: mpi_io_bench binary (h5bench-equivalent MPI-IO throughput bench).

mpi_vol.rs reviewer fixes:
- Doc-comment updated to accurately describe root-read+broadcast pattern
  (not MPI_File_read_at); MpiVol::expected_capabilities() associated fn
  added so tests can verify capabilities without a live MPI universe;
  rank_and_size_stub_values renamed to no_feature_error_contains_feature_name.

Workspace check: zero warnings, 20 test suites pass.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-30 11:41:41 +00:00
co-authored by Claude Sonnet 4.6
parent 90bdd7cd13
commit d6c4d4f111
16 changed files with 1121 additions and 41 deletions
+1
View File
@@ -17,6 +17,7 @@ members = [
"crates/clawhdf5-cli", "crates/clawhdf5-cli",
"crates/clawhdf5-napi", "crates/clawhdf5-napi",
"crates/clawhdf5-bench", "crates/clawhdf5-bench",
"crates/libaec-sys",
] ]
resolver = "2" resolver = "2"
+8
View File
@@ -25,6 +25,11 @@ path = "src/bin/consolidation_efficiency.rs"
name = "ephemeral_perf" name = "ephemeral_perf"
path = "src/bin/ephemeral_perf.rs" path = "src/bin/ephemeral_perf.rs"
[[bin]]
name = "mpi_io_bench"
path = "src/bin/mpi_io_bench.rs"
required-features = ["mpi-io"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# h5bench-equivalent Criterion benchmarks # h5bench-equivalent Criterion benchmarks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -43,6 +48,8 @@ harness = false
[dependencies] [dependencies]
clawhdf5-agent = { path = "../clawhdf5-agent" } clawhdf5-agent = { path = "../clawhdf5-agent" }
clawhdf5-io = { path = "../clawhdf5-io" }
mpi = { version = "0.8", optional = true }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tempfile = "3" tempfile = "3"
@@ -57,3 +64,4 @@ criterion = { version = "0.5", features = ["html_reports"] }
[features] [features]
# When enabled, benchmarks add matching libhdf5 variants for side-by-side comparison. # When enabled, benchmarks add matching libhdf5 variants for side-by-side comparison.
libhdf5-compare = ["hdf5"] libhdf5-compare = ["hdf5"]
mpi-io = ["clawhdf5-io/mpi-io", "mpi"]
@@ -0,0 +1,67 @@
//! h5bench-equivalent MPI-IO performance benchmark.
//!
//! Usage: mpirun -np N cargo run -p clawhdf5-bench --features mpi-io --bin mpi_io_bench -- --size <N>
//!
//! Measures collective write and read throughput in MB/s for f64 arrays.
#[cfg(feature = "mpi-io")]
fn main() {
use clawhdf5_io::mpi_vol::MpiVol;
use clawhdf5_io::vol::VirtualObjectLayer;
use mpi::traits::*;
use std::time::Instant;
let args: Vec<String> = std::env::args().collect();
let n_elements: usize = args
.iter()
.position(|a| a == "--size")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse().ok())
.unwrap_or(100_000);
let mut vol = MpiVol::new_world().expect("MPI init failed");
let world = vol.universe.world();
let rank = world.rank() as usize;
let size = world.size() as usize;
let path = format!("/tmp/clawhdf5_mpiio_bench_{n_elements}.h5");
vol.open(&path).unwrap();
// Each rank contributes n_elements/size f64 values
let per_rank = n_elements / size;
let shard: Vec<f64> = (0..per_rank)
.map(|i| (rank * per_rank + i) as f64)
.collect();
let shard_bytes: Vec<u8> = shard.iter().flat_map(|v| v.to_le_bytes()).collect();
// Collective write
world.barrier();
let t0 = Instant::now();
vol.write_dataset("data", &shard_bytes, &[n_elements as u64], "f64")
.unwrap();
world.barrier();
let write_elapsed = t0.elapsed().as_secs_f64();
// Collective read
let t1 = Instant::now();
let _data = vol.read_dataset("data").unwrap();
world.barrier();
let read_elapsed = t1.elapsed().as_secs_f64();
if rank == 0 {
let total_mb = (n_elements * 8) as f64 / 1e6;
println!("=== clawhdf5 MPI-IO Benchmark ===");
println!("Elements : {n_elements}");
println!("Ranks : {size}");
println!("Total : {total_mb:.1} MB");
println!("Write : {:.1} MB/s", total_mb / write_elapsed);
println!("Read : {:.1} MB/s", total_mb / read_elapsed);
}
}
#[cfg(not(feature = "mpi-io"))]
fn main() {
eprintln!("mpi_io_bench requires the `mpi-io` feature.");
eprintln!("Run: mpirun -np N cargo run -p clawhdf5-bench --features mpi-io --bin mpi_io_bench");
std::process::exit(1);
}
+2
View File
@@ -18,6 +18,7 @@ crc32fast = { version = "1", optional = true }
lz4_flex = { version = "0.11", optional = true } lz4_flex = { version = "0.11", optional = true }
zstd = { version = "0.13", optional = true } zstd = { version = "0.13", optional = true }
blake3 = { version = "1", optional = true } blake3 = { version = "1", optional = true }
libaec-sys = { path = "../libaec-sys", version = "0.1", optional = true }
[dev-dependencies] [dev-dependencies]
serde_json = "1" serde_json = "1"
@@ -43,6 +44,7 @@ zlib-rs = ["flate2/zlib-rs"]
lz4 = ["lz4_flex"] lz4 = ["lz4_flex"]
zstd = ["dep:zstd"] zstd = ["dep:zstd"]
blake3_hash = ["blake3"] blake3_hash = ["blake3"]
szip = ["libaec-sys"]
[[bench]] [[bench]]
name = "parallel_decompress_bench" name = "parallel_decompress_bench"
@@ -0,0 +1,200 @@
//! Write-side helpers for VDS (Virtual Dataset Source) mapping serialization.
//!
//! [`serialize_vds_mappings`] produces the byte blob stored in a global heap
//! object and referenced from a Data Layout v4 class=3 (Virtual) message.
//! Its output is byte-compatible with what [`crate::data_layout::parse_vds_mappings`]
//! can parse back.
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
use crate::data_layout::VdsMapping;
/// Serialize a slice of [`VdsMapping`]s into the global-heap object byte format.
///
/// # Layout
///
/// ```text
/// version(1) · nused(length_size, LE) · entry[nused]
/// ```
///
/// Each entry:
/// - **version 0** (at least one external source file): null-terminated source
/// file name, then null-terminated source dataset name, then source selection
/// bytes (self-describing), then virtual selection bytes (self-describing).
/// - **version 1** (all same-file): a single `0x04` marker byte in place of the
/// file name, then null-terminated source dataset name, then the two
/// self-describing selection blobs.
///
/// The selections are written as-is from [`VdsMapping::source_selection`] and
/// [`VdsMapping::virtual_selection`]; the caller is responsible for ensuring
/// they are valid serialized `H5S` selections that [`crate::selection::Selection::decode_serialized`]
/// can consume.
///
/// `length_size` must be 2, 4, or 8; any other value falls back to 8.
pub fn serialize_vds_mappings(mappings: &[VdsMapping], length_size: u8) -> Vec<u8> {
let mut buf = Vec::new();
// Block version 0 = at least one external (non-same-file) source;
// block version 1 = all sources are in the same file (source_file == ".").
let all_same_file = mappings
.iter()
.all(|m| m.source_file.is_empty() || m.source_file == ".");
let version: u8 = if all_same_file { 1 } else { 0 };
buf.push(version);
// nused: number of mappings, encoded as little-endian `length_size` bytes.
write_length(&mut buf, mappings.len() as u64, length_size);
for m in mappings {
if version == 0 {
// External file: write the file name as a null-terminated string.
buf.extend_from_slice(m.source_file.as_bytes());
buf.push(0u8);
} else {
// Same-file: the marker byte that `parse_vds_mappings` recognises as
// the same-file sentinel (0x04).
buf.push(0x04u8);
}
// Source dataset path: null-terminated string.
buf.extend_from_slice(m.source_dataset.as_bytes());
buf.push(0u8);
// Source selection: raw self-describing bytes (no separate length prefix).
buf.extend_from_slice(&m.source_selection);
// Virtual selection: raw self-describing bytes (no separate length prefix).
buf.extend_from_slice(&m.virtual_selection);
}
buf
}
/// Encode `val` as a little-endian integer of `size` bytes and push it into
/// `buf`. Supported sizes: 2, 4, 8. Any other value falls back to 8 bytes.
pub(crate) fn write_length(buf: &mut Vec<u8>, val: u64, size: u8) {
match size {
2 => buf.extend_from_slice(&(val as u16).to_le_bytes()),
4 => buf.extend_from_slice(&(val as u32).to_le_bytes()),
_ => buf.extend_from_slice(&val.to_le_bytes()),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::data_layout::parse_vds_mappings;
/// A minimal, valid serialized H5S ALL selection (type=3, 16 bytes).
///
/// Layout: type(4 LE) + version(4 LE) + reserved(4) + length(4) = 16 bytes.
/// `decode_serialized` consumes exactly 16 bytes for ALL/NONE.
fn all_sel() -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(&3u32.to_le_bytes()); // type = H5S_SEL_ALL (3)
v.extend_from_slice(&1u32.to_le_bytes()); // version = 1
v.extend_from_slice(&[0u8; 4]); // reserved
v.extend_from_slice(&[0u8; 4]); // length field (unused for ALL)
v
}
#[test]
fn roundtrip_same_file_two_mappings() {
let sel = all_sel();
let mappings = vec![
VdsMapping {
source_file: ".".into(),
source_dataset: "/src_a".into(),
source_selection: sel.clone(),
virtual_selection: sel.clone(),
},
VdsMapping {
source_file: ".".into(),
source_dataset: "/src_b".into(),
source_selection: sel.clone(),
virtual_selection: sel.clone(),
},
];
let bytes = serialize_vds_mappings(&mappings, 8);
// Block version must be 1 (same-file).
assert_eq!(bytes[0], 1u8);
let parsed = parse_vds_mappings(&bytes, 8).unwrap();
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].source_file, ".");
assert_eq!(parsed[0].source_dataset, "/src_a");
assert_eq!(parsed[1].source_file, ".");
assert_eq!(parsed[1].source_dataset, "/src_b");
}
#[test]
fn roundtrip_external_file_mapping() {
let sel = all_sel();
let mappings = vec![VdsMapping {
source_file: "source.h5".into(),
source_dataset: "/data".into(),
source_selection: sel.clone(),
virtual_selection: sel.clone(),
}];
let bytes = serialize_vds_mappings(&mappings, 8);
// Block version must be 0 (external file present).
assert_eq!(bytes[0], 0u8);
let parsed = parse_vds_mappings(&bytes, 8).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].source_file, "source.h5");
assert_eq!(parsed[0].source_dataset, "/data");
assert_eq!(
parsed[0].source_selection, sel,
"source selection bytes must survive round-trip"
);
assert_eq!(
parsed[0].virtual_selection, sel,
"virtual selection bytes must survive round-trip"
);
}
#[test]
fn empty_mappings_roundtrip() {
// Empty slice: version 1 (vacuously all same-file), nused=0.
let bytes = serialize_vds_mappings(&[], 8);
let parsed = parse_vds_mappings(&bytes, 8).unwrap();
assert!(parsed.is_empty());
}
#[test]
fn roundtrip_empty_source_file_treated_as_same_file() {
// An empty source_file string is also treated as same-file (version 1).
let sel = all_sel();
let mappings = vec![VdsMapping {
source_file: String::new(),
source_dataset: "/ds".into(),
source_selection: sel.clone(),
virtual_selection: sel.clone(),
}];
let bytes = serialize_vds_mappings(&mappings, 8);
assert_eq!(bytes[0], 1u8);
let parsed = parse_vds_mappings(&bytes, 8).unwrap();
assert_eq!(parsed.len(), 1);
// parse_vds_mappings turns the 0x04 marker into "."
assert_eq!(parsed[0].source_file, ".");
}
#[test]
fn roundtrip_length_size_4() {
let sel = all_sel();
let mappings = vec![VdsMapping {
source_file: ".".into(),
source_dataset: "/x".into(),
source_selection: sel.clone(),
virtual_selection: sel.clone(),
}];
let bytes = serialize_vds_mappings(&mappings, 4);
let parsed = parse_vds_mappings(&bytes, 4).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].source_dataset, "/x");
}
}
+73 -11
View File
@@ -170,6 +170,18 @@ pub(crate) fn make_link(name: &str, addr: u64) -> LinkMessage {
} }
} }
pub(crate) fn make_external_link(name: &str, filename: &str, object_path: &str) -> LinkMessage {
LinkMessage {
name: name.to_string(),
link_target: LinkTarget::External {
filename: filename.to_string(),
object_path: object_path.to_string(),
},
creation_order: None,
charset: CharacterSet::Ascii,
}
}
// ---- Dense attribute blob ---- // ---- Dense attribute blob ----
/// Pre-built dense attribute storage (fractal heap + B-tree v2 + attribute info message). /// Pre-built dense attribute storage (fractal heap + B-tree v2 + attribute info message).
@@ -999,6 +1011,8 @@ impl FileWriter {
name: String, name: String,
attrs: Vec<AttributeMessage>, attrs: Vec<AttributeMessage>,
ds_indices: Vec<usize>, ds_indices: Vec<usize>,
/// (link_name, target_file, target_path)
external_links: Vec<(String, String, String)>,
} }
// Helper: convert a DatasetBuilder into DsFlat, handling VDS (which // Helper: convert a DatasetBuilder into DsFlat, handling VDS (which
@@ -1075,6 +1089,7 @@ impl FileWriter {
name: g.name, name: g.name,
attrs: gattrs, attrs: gattrs,
ds_indices: ds_idx, ds_indices: ds_idx,
external_links: g.external_links,
}); });
} }
@@ -1114,7 +1129,7 @@ impl FileWriter {
let root_links_dense = root_link_count > DENSE_LINK_THRESHOLD; let root_links_dense = root_link_count > DENSE_LINK_THRESHOLD;
let group_links_dense: Vec<bool> = groups let group_links_dense: Vec<bool> = groups
.iter() .iter()
.map(|g| g.ds_indices.len() > DENSE_LINK_THRESHOLD) .map(|g| g.ds_indices.len() + g.external_links.len() > DENSE_LINK_THRESHOLD)
.collect(); .collect();
// The dense LinkInfo message is a fixed size regardless of address, so a // The dense LinkInfo message is a fixed size regardless of address, so a
// dummy is sufficient for OH size computation. // dummy is sufficient for OH size computation.
@@ -1125,11 +1140,14 @@ impl FileWriter {
.iter() .iter()
.enumerate() .enumerate()
.map(|(gi, g)| { .map(|(gi, g)| {
let dummy_links: Vec<LinkMessage> = g let mut dummy_links: Vec<LinkMessage> = g
.ds_indices .ds_indices
.iter() .iter()
.map(|&i| make_link(&all_ds[i].name, 0)) .map(|&i| make_link(&all_ds[i].name, 0))
.collect(); .collect();
for (lname, fname, opath) in &g.external_links {
dummy_links.push(make_external_link(lname, fname, opath));
}
let attr_blob = group_dense[gi].then(|| build_dense_attrs(&g.attrs, 0)); let attr_blob = group_dense[gi].then(|| build_dense_attrs(&g.attrs, 0));
let dl = group_links_dense[gi].then_some(dummy_link_info.as_slice()); let dl = group_links_dense[gi].then_some(dummy_link_info.as_slice());
build_group_oh(&dummy_links, dl, &g.attrs, attr_blob.as_ref()).len() build_group_oh(&dummy_links, dl, &g.attrs, attr_blob.as_ref()).len()
@@ -1289,11 +1307,14 @@ impl FileWriter {
let addr = cursor2 as u64; let addr = cursor2 as u64;
cursor2 += sz; cursor2 += sz;
if group_links_dense[gi] { if group_links_dense[gi] {
let dummy_links: Vec<LinkMessage> = groups[gi] let mut dummy_links: Vec<LinkMessage> = groups[gi]
.ds_indices .ds_indices
.iter() .iter()
.map(|&i| make_link(&all_ds[i].name, 0)) .map(|&i| make_link(&all_ds[i].name, 0))
.collect(); .collect();
for (lname, fname, opath) in &groups[gi].external_links {
dummy_links.push(make_external_link(lname, fname, opath));
}
let blob_addr = cursor2 as u64; let blob_addr = cursor2 as u64;
cursor2 += build_dense_links(&dummy_links, blob_addr).blob.len(); cursor2 += build_dense_links(&dummy_links, blob_addr).blob.len();
group_link_blob_addrs.push(Some(blob_addr)); group_link_blob_addrs.push(Some(blob_addr));
@@ -1470,11 +1491,14 @@ impl FileWriter {
// Group OHs + dense blobs (link blob, then attr blob, matching pass 2) // Group OHs + dense blobs (link blob, then attr blob, matching pass 2)
for (gi, g) in groups.iter().enumerate() { for (gi, g) in groups.iter().enumerate() {
let links: Vec<LinkMessage> = g let mut links: Vec<LinkMessage> = g
.ds_indices .ds_indices
.iter() .iter()
.map(|&i| make_link(&all_ds[i].name, ds_oh_addrs2[i])) .map(|&i| make_link(&all_ds[i].name, ds_oh_addrs2[i]))
.collect(); .collect();
for (lname, fname, opath) in &g.external_links {
links.push(make_external_link(lname, fname, opath));
}
let link_blob = group_link_blob_addrs[gi].map(|addr| build_dense_links(&links, addr)); let link_blob = group_link_blob_addrs[gi].map(|addr| build_dense_links(&links, addr));
let dl = link_blob.as_ref().map(|b| b.link_info_message.as_slice()); let dl = link_blob.as_ref().map(|b| b.link_info_message.as_slice());
buf.extend_from_slice(&build_group_oh( buf.extend_from_slice(&build_group_oh(
@@ -2008,6 +2032,8 @@ mod tests {
#[test] #[test]
fn vds_empty_mapping_list() { fn vds_empty_mapping_list() {
// Calling with_virtual_sources([]) is silently ignored — the dataset
// falls back to a normal contiguous layout rather than writing an empty VDS.
use crate::data_layout::DataLayout; use crate::data_layout::DataLayout;
let mut fw = FileWriter::new(); let mut fw = FileWriter::new();
@@ -2029,15 +2055,51 @@ mod tests {
.find(|m| m.msg_type == MessageType::DataLayout) .find(|m| m.msg_type == MessageType::DataLayout)
.unwrap() .unwrap()
.data; .data;
let mut layout = let layout = DataLayout::parse(dl_data, sb.offset_size, sb.length_size).unwrap();
DataLayout::parse(dl_data, sb.offset_size, sb.length_size).unwrap();
layout.resolve_vds_mappings(&bytes, sb.length_size).unwrap();
match &layout { // Empty mapping list → no VDS layout; should be Contiguous or Compact.
DataLayout::Virtual { mappings, .. } => { assert!(
assert_eq!(mappings.len(), 0, "expected empty mappings for zero-mapping VDS"); !matches!(layout, DataLayout::Virtual { .. }),
"empty with_virtual_sources should NOT produce a VDS layout, got {layout:?}"
);
}
#[test]
fn external_link_write_roundtrip() {
let mut fw = FileWriter::new();
let mut grp = fw.create_group("sensors");
grp.create_dataset("local_ds").with_f64_data(&[1.0, 2.0]);
grp.add_external_link("remote_temp", "other_file.h5", "/temperature");
fw.add_group(grp.finish());
let bytes = fw.finish().unwrap();
let sig = signature::find_signature(&bytes).unwrap();
let sb = Superblock::parse(&bytes, sig).unwrap();
let sensors_addr = resolve_path_any(&bytes, &sb, "sensors").unwrap();
let hdr = ObjectHeader::parse(
&bytes,
sensors_addr as usize,
sb.offset_size,
sb.length_size,
)
.unwrap();
// Find the external LinkMessage directly in the object header.
let ext_link = hdr
.messages
.iter()
.filter(|m| m.msg_type == MessageType::Link)
.filter_map(|m| crate::link_message::LinkMessage::parse(&m.data, sb.offset_size).ok())
.find(|l| l.name == "remote_temp")
.expect("external link 'remote_temp' not found in group OH");
match &ext_link.link_target {
crate::link_message::LinkTarget::External { filename, object_path } => {
assert_eq!(filename, "other_file.h5");
assert_eq!(object_path, "/temperature");
} }
other => panic!("expected Virtual, got {other:?}"), other => panic!("expected External link, got {other:?}"),
} }
} }
} }
+61 -28
View File
@@ -9,7 +9,7 @@ use alloc::{vec, vec::Vec};
use crate::error::FormatError; use crate::error::FormatError;
use crate::filter_pipeline::{ use crate::filter_pipeline::{
FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_NBIT, FILTER_SCALEOFFSET, FILTER_SHUFFLE, FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_NBIT, FILTER_SCALEOFFSET, FILTER_SHUFFLE,
FILTER_ZSTD, FilterPipeline, FILTER_SZIP, FILTER_ZSTD, FilterPipeline,
}; };
/// Apply a filter pipeline to decompress a chunk. /// Apply a filter pipeline to decompress a chunk.
@@ -33,6 +33,7 @@ pub fn decompress_chunk(
// decoders can reject an element count that would over-allocate. // decoders can reject an element count that would over-allocate.
FILTER_SCALEOFFSET => scaleoffset_decompress(&data, &filter.client_data, chunk_size)?, FILTER_SCALEOFFSET => scaleoffset_decompress(&data, &filter.client_data, chunk_size)?,
FILTER_NBIT => nbit_decompress(&data, &filter.client_data, chunk_size)?, FILTER_NBIT => nbit_decompress(&data, &filter.client_data, chunk_size)?,
FILTER_SZIP => crate::filters_szip::szip_decompress(&data, &filter.client_data, chunk_size)?,
other => return Err(FormatError::UnsupportedFilter(other)), other => return Err(FormatError::UnsupportedFilter(other)),
}; };
} }
@@ -71,29 +72,28 @@ pub fn compress_chunk(
/// Decode the HDF5 scale-offset filter (id 6). /// Decode the HDF5 scale-offset filter (id 6).
/// ///
/// Supports the integer variant (`H5Z_SO_INT`) and the floating-point /// Supports all three scale-offset variants:
/// **D-scale** variant (`H5Z_SO_FLOAT_DSCALE`); the float E-scale variant is /// - `H5Z_SO_FLOAT_DSCALE` (0): `value = minval + code / 10^D`
/// reported as unsupported. /// - `H5Z_SO_FLOAT_ESCALE` (1): `value = minval + code * 2^E`
/// - `H5Z_SO_INT` (2): `value = minval + code`
/// ///
/// Compressed buffer layout (reverse-engineered against HDF5 2.0 and verified /// Compressed buffer layout: `minbits` (u32 LE) · `minval_width` (1 byte)
/// across signed/unsigned int sizes, f32/f64, negatives, fill values and chunk /// · `minval` (`minval_width` bytes) · 8 reserved bytes · MSB-first packed
/// sizes): `minbits` (u32 LE) · `minval_width` (1 byte) · `minval` /// codes (`nelmts * minbits` bits). The all-ones code is reserved for the
/// (`minval_width` bytes — a little-endian integer for the int variant, or the /// defined fill value.
/// minimum float for D-scale) · 8 reserved bytes · MSB-first packed codes
/// (`nelmts * minbits` bits). The all-ones code is reserved for the (defined)
/// fill value. Integer reconstruction is `value = minval + code`; D-scale float
/// is `value = minval + code / 10^scale_factor`.
/// ///
/// `cd` is the `H5Zscaleoffset.c` parameter block: `[0]`=scale type /// `cd` is the `H5Zscaleoffset.c` parameter block: `[0]`=scale type,
/// (0 = float D-scale, 2 = integer), `[1]`=scale factor (decimal digits for /// `[1]`=scale factor (decimal digits D for D-scale, binary exponent E for
/// D-scale), `[2]`=element count, `[4]`=element size, `[5]`=signed flag, /// E-scale, interpreted as i32 for negative exponents), `[2]`=element count,
/// `[6]`=byte order (1 = big-endian), `[7]`=fill defined, `[8..]`=fill value. /// `[4]`=element size, `[5]`=signed flag, `[6]`=byte order (1 = big-endian),
/// `[7]`=fill defined, `[8..]`=fill value bits.
fn scaleoffset_decompress( fn scaleoffset_decompress(
data: &[u8], data: &[u8],
cd: &[u32], cd: &[u32],
expected_bytes: usize, expected_bytes: usize,
) -> Result<Vec<u8>, FormatError> { ) -> Result<Vec<u8>, FormatError> {
const H5Z_SO_FLOAT_DSCALE: u32 = 0; const H5Z_SO_FLOAT_DSCALE: u32 = 0;
const H5Z_SO_FLOAT_ESCALE: u32 = 1;
const H5Z_SO_INT: u32 = 2; const H5Z_SO_INT: u32 = 2;
if cd.len() < 8 { if cd.len() < 8 {
return Err(FormatError::ChunkedReadError( return Err(FormatError::ChunkedReadError(
@@ -101,9 +101,8 @@ fn scaleoffset_decompress(
)); ));
} }
let scale_type = cd[0]; let scale_type = cd[0];
let is_float = scale_type == H5Z_SO_FLOAT_DSCALE; let is_float = scale_type == H5Z_SO_FLOAT_DSCALE || scale_type == H5Z_SO_FLOAT_ESCALE;
if scale_type != H5Z_SO_INT && !is_float { if scale_type != H5Z_SO_INT && !is_float {
// Float E-scale (scale type 1) uses a different algorithm.
return Err(FormatError::UnsupportedFilter(FILTER_SCALEOFFSET)); return Err(FormatError::UnsupportedFilter(FILTER_SCALEOFFSET));
} }
let nelmts = cd[2] as usize; let nelmts = cd[2] as usize;
@@ -190,7 +189,8 @@ fn scaleoffset_decompress(
}; };
if is_float { if is_float {
let scale = 10f64.powi(cd[1] as i32); let is_escale = scale_type == H5Z_SO_FLOAT_ESCALE;
let scale_factor = cd[1] as i32;
let minval = read_le_float(minval_bytes, elem_size); let minval = read_le_float(minval_bytes, elem_size);
let fill_value = if fill_defined { let fill_value = if fill_defined {
let lo = *cd.get(8).unwrap_or(&0) as u64; let lo = *cd.get(8).unwrap_or(&0) as u64;
@@ -204,8 +204,10 @@ fn scaleoffset_decompress(
.map(|&code| { .map(|&code| {
if has_fill_code && code == fill_code { if has_fill_code && code == fill_code {
fill_value fill_value
} else if is_escale {
minval + code as f64 * 2f64.powi(scale_factor)
} else { } else {
minval + code as f64 / scale minval + code as f64 / 10f64.powi(scale_factor)
} }
}) })
.collect(); .collect();
@@ -1289,15 +1291,46 @@ mod tests {
} }
} }
fn as_f64(bytes: &[u8]) -> Vec<f64> {
bytes
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.collect()
}
#[test] #[test]
fn scaleoffset_float_escale_unsupported() { fn scaleoffset_float_escale_e1() {
// scale_type 1 = float E-scale — a different algorithm, must be rejected. // f64 [0.0, 2.0, 4.0, 6.0], E=1 (×2^1=2), fill_defined=0.
let cd = [1u32, 3, 50, 1, 4, 0, 0, 1, 0]; // cd: scale_type=1, E=1, nelmts=4, elem_size=8.
let raw = [0u8; 24]; let cd = [1u32, 1, 4, 0, 8, 0, 0, 0];
assert!(matches!( let raw: &[u8] = &[
scaleoffset_decompress(&raw, &cd, 0), 2, 0, 0, 0, // minbits=2
Err(FormatError::UnsupportedFilter(FILTER_SCALEOFFSET)) 8, // minval_width=8
)); 0, 0, 0, 0, 0, 0, 0, 0, // minval=0.0f64
0, 0, 0, 0, 0, 0, 0, 0, // 8 reserved bytes
0x1B, // packed codes: 00 01 10 11 MSB-first
];
let got = as_f64(&scaleoffset_decompress(raw, &cd, 0).unwrap());
assert_eq!(got, vec![0.0, 2.0, 4.0, 6.0]);
}
#[test]
fn scaleoffset_float_escale_neg_exp() {
// f64 [0.0, 0.5, 1.0, 1.5], E=-1 (×2^-1=0.5), fill_defined=0.
// cd[1] = 0xFFFF_FFFF which casts to i32 = -1.
let cd = [1u32, 0xFFFF_FFFF, 4, 0, 8, 0, 0, 0];
let raw: &[u8] = &[
2, 0, 0, 0, // minbits=2
8, // minval_width=8
0, 0, 0, 0, 0, 0, 0, 0, // minval=0.0f64
0, 0, 0, 0, 0, 0, 0, 0, // 8 reserved bytes
0x1B, // packed codes: 00 01 10 11 MSB-first
];
let got = as_f64(&scaleoffset_decompress(raw, &cd, 0).unwrap());
let exp = [0.0f64, 0.5, 1.0, 1.5];
for (g, e) in got.iter().zip(exp.iter()) {
assert!((g - e).abs() < 1e-9, "got {g} expected {e}");
}
} }
// --- N-Bit (filter id 5) -------------------------------------------------- // --- N-Bit (filter id 5) --------------------------------------------------
+110
View File
@@ -0,0 +1,110 @@
//! SZIP (libaec Adaptive Entropy Coding) decompression.
//!
//! Gated by the `szip` feature which links against the system libaec library.
use crate::error::FormatError;
/// Decompress SZIP-compressed data using libaec.
///
/// `cd` is the HDF5 filter client data:
/// cd[0] = options mask (NN flag = 0x04, LSB = 0x40, allow_k13 = 0x100)
/// cd[1] = pixels per block (8, 10, 16, or 32)
/// cd[2] = pixels per scan line
/// cd[4] = bits per sample (element bit width)
pub(crate) fn szip_decompress(
_data: &[u8],
_cd: &[u32],
_chunk_size: usize,
) -> Result<Vec<u8>, FormatError> {
#[cfg(feature = "szip")]
{
szip_decode_impl(_data, _cd, _chunk_size)
}
#[cfg(not(feature = "szip"))]
{
Err(FormatError::UnsupportedFilter(
crate::filter_pipeline::FILTER_SZIP,
))
}
}
#[cfg(feature = "szip")]
fn szip_decode_impl(data: &[u8], cd: &[u32], chunk_size: usize) -> Result<Vec<u8>, FormatError> {
if cd.len() < 5 {
return Err(FormatError::ChunkedReadError(
"szip: missing client data".into(),
));
}
let options = cd[0];
let pixels_per_block = cd[1];
let bits_per_sample = cd[4];
if bits_per_sample == 0 || bits_per_sample > 32 {
return Err(FormatError::ChunkedReadError(
"szip: invalid bits per sample".into(),
));
}
if chunk_size == 0 {
return Err(FormatError::ChunkedReadError(
"szip: unknown output size".into(),
));
}
// Map HDF5 options mask to libaec flags.
// bit 2 (0x04): NN (nearest-neighbor) preprocessing
// bit 5 (0x20): EC (entropy coding) — handled internally by libaec
// bit 6 (0x40): LSB order; absence means MSB
// bit 8 (0x100): allow k=13
let mut flags: u32 = 0;
if options & 0x04 != 0 {
flags |= libaec_sys::AEC_DATA_PREPROCESS;
}
if options & 0x40 == 0 {
flags |= libaec_sys::AEC_DATA_MSB;
}
if options & 0x100 != 0 {
flags |= libaec_sys::AEC_ALLOW_K13;
}
let mut out_len: usize = chunk_size;
let mut out = vec![0u8; chunk_size];
let result = unsafe {
libaec_sys::aec_buffer_decode(
data.as_ptr(),
data.len(),
out.as_mut_ptr(),
&mut out_len,
bits_per_sample,
pixels_per_block,
flags,
)
};
if result != 0 {
return Err(FormatError::DecompressionError(format!(
"szip: libaec error {result}"
)));
}
out.truncate(out_len);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn szip_disabled_returns_unsupported() {
#[cfg(not(feature = "szip"))]
{
let result = szip_decompress(&[], &[4, 8, 10, 0, 8], 64);
assert!(
matches!(result, Err(FormatError::UnsupportedFilter(4))),
"expected UnsupportedFilter(4), got {result:?}"
);
}
#[cfg(feature = "szip")]
{
// When szip IS enabled, an empty buffer should error but not panic.
let result = szip_decompress(&[], &[4, 8, 10, 0, 8], 64);
assert!(result.is_err(), "empty buffer must not succeed");
}
}
}
+2
View File
@@ -58,6 +58,7 @@ pub mod chunk_index;
pub mod chunked_read; pub mod chunked_read;
pub mod chunked_write; pub mod chunked_write;
pub mod data_layout; pub mod data_layout;
pub mod data_layout_write;
pub mod data_read; pub mod data_read;
pub mod dataspace; pub mod dataspace;
pub mod datatype; pub mod datatype;
@@ -68,6 +69,7 @@ pub mod extensible_array;
pub mod file_writer; pub mod file_writer;
pub mod filter_pipeline; pub mod filter_pipeline;
pub mod filters; pub mod filters;
mod filters_szip;
pub mod fixed_array; pub mod fixed_array;
pub mod fractal_heap; pub mod fractal_heap;
pub mod global_heap; pub mod global_heap;
+26 -2
View File
@@ -602,9 +602,12 @@ impl DatasetBuilder {
/// ///
/// `datatype` and `shape` must still be set via `with_*_data()` or /// `datatype` and `shape` must still be set via `with_*_data()` or
/// `with_shape()` / `with_f64_data()` etc.; the actual raw bytes are /// `with_shape()` / `with_f64_data()` etc.; the actual raw bytes are
/// not written for VDS datasets. /// not written for VDS datasets. A non-empty `mappings` list is required;
/// an empty list is silently ignored (no VDS layout is written).
pub fn with_virtual_sources(&mut self, mappings: Vec<VdsMapping>) -> &mut Self { pub fn with_virtual_sources(&mut self, mappings: Vec<VdsMapping>) -> &mut Self {
self.virtual_sources = Some(mappings); if !mappings.is_empty() {
self.virtual_sources = Some(mappings);
}
self self
} }
@@ -635,6 +638,8 @@ pub struct GroupBuilder {
pub(crate) name: String, pub(crate) name: String,
pub(crate) datasets: Vec<DatasetBuilder>, pub(crate) datasets: Vec<DatasetBuilder>,
pub(crate) attrs: Vec<(String, AttrValue)>, pub(crate) attrs: Vec<(String, AttrValue)>,
/// (link_name, target_file, target_path)
pub(crate) external_links: Vec<(String, String, String)>,
} }
impl GroupBuilder { impl GroupBuilder {
@@ -643,6 +648,7 @@ impl GroupBuilder {
name: name.to_string(), name: name.to_string(),
datasets: Vec::new(), datasets: Vec::new(),
attrs: Vec::new(), attrs: Vec::new(),
external_links: Vec::new(),
} }
} }
@@ -655,12 +661,28 @@ impl GroupBuilder {
self.attrs.push((name.to_string(), value)); self.attrs.push((name.to_string(), value));
} }
/// Add an external link: a named pointer to an object in another HDF5 file.
pub fn add_external_link(
&mut self,
name: &str,
target_file: &str,
target_path: &str,
) -> &mut Self {
self.external_links.push((
name.to_string(),
target_file.to_string(),
target_path.to_string(),
));
self
}
/// Consume the builder, returning a FinishedGroup to add to FileWriter. /// Consume the builder, returning a FinishedGroup to add to FileWriter.
pub fn finish(self) -> FinishedGroup { pub fn finish(self) -> FinishedGroup {
FinishedGroup { FinishedGroup {
name: self.name, name: self.name,
datasets: self.datasets, datasets: self.datasets,
attrs: self.attrs, attrs: self.attrs,
external_links: self.external_links,
} }
} }
} }
@@ -670,4 +692,6 @@ pub struct FinishedGroup {
pub(crate) name: String, pub(crate) name: String,
pub(crate) datasets: Vec<DatasetBuilder>, pub(crate) datasets: Vec<DatasetBuilder>,
pub(crate) attrs: Vec<(String, AttrValue)>, pub(crate) attrs: Vec<(String, AttrValue)>,
/// (link_name, target_file, target_path)
pub(crate) external_links: Vec<(String, String, String)>,
} }
+3
View File
@@ -17,12 +17,15 @@ tokio = { version = "1", features = ["fs", "io-util"], optional = true }
reqwest = { version = "0.12", features = ["json"], optional = true } reqwest = { version = "0.12", features = ["json"], optional = true }
serde = { version = "1", features = ["derive"], optional = true } serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true } serde_json = { version = "1", optional = true }
mpi = { version = "0.8", optional = true }
[dev-dependencies] [dev-dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tempfile = "3"
[features] [features]
default = [] default = []
mmap = ["memmap2", "libc"] mmap = ["memmap2", "libc"]
async = ["tokio"] async = ["tokio"]
hsds = ["reqwest", "serde", "serde_json", "async"] hsds = ["reqwest", "serde", "serde_json", "async"]
mpi-io = ["mpi"]
+2
View File
@@ -281,6 +281,8 @@ pub mod mmap;
#[cfg(feature = "mmap")] #[cfg(feature = "mmap")]
pub use mmap::{MmapReadWrite, MmapReader}; pub use mmap::{MmapReadWrite, MmapReader};
pub mod mpi_vol;
pub use mpi_vol::MpiVol;
pub mod prefetch; pub mod prefetch;
pub mod subfiling; pub mod subfiling;
pub mod sweep; pub mod sweep;
+510
View File
@@ -0,0 +1,510 @@
//! MPI-IO VOL connector for parallel HDF5 reads and writes.
//!
//! Enable with the `mpi-io` feature: `cargo build --features mpi-io`.
//!
//! # Parallelism model
//!
//! **Read**: rank 0 reads the full file with `std::fs::read`, parses the
//! requested dataset, then broadcasts the raw bytes to all other ranks via
//! MPI broadcast. This is a root-read + broadcast pattern, *not* true
//! collective I/O (`MPI_File_read_at_all`).
//!
//! **Write**: each rank gathers its data shard to rank 0, which stitches
//! the contributions and writes the merged dataset atomically to disk. A
//! barrier ensures all ranks observe the completed file before continuing.
use crate::vol::{VirtualObjectLayer, VolCapability, VolError};
#[cfg(feature = "mpi-io")]
use mpi::traits::*;
/// Rank within the communicator.
type Rank = i32;
/// MPI-IO Virtual Object Layer connector.
///
/// Wraps an MPI communicator for collective HDF5 file I/O.
pub struct MpiVol {
location: Option<String>,
#[cfg(feature = "mpi-io")]
pub universe: mpi::environment::Universe,
#[cfg(not(feature = "mpi-io"))]
_placeholder: (),
}
impl std::fmt::Debug for MpiVol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MpiVol")
.field("location", &self.location)
.finish_non_exhaustive()
}
}
impl MpiVol {
/// Create an `MpiVol` using `MPI_COMM_WORLD`.
///
/// Initializes MPI if not already initialized. Call once per process.
#[cfg(feature = "mpi-io")]
pub fn new_world() -> Result<Self, VolError> {
let universe = mpi::initialize()
.ok_or_else(|| VolError::Unsupported("MPI already finalized or init failed".into()))?;
Ok(Self {
location: None,
universe,
})
}
/// Stub for when the feature is disabled.
#[cfg(not(feature = "mpi-io"))]
pub fn new_world() -> Result<Self, VolError> {
Err(VolError::Unsupported(
"MPI-IO support requires the `mpi-io` feature".into(),
))
}
/// Returns the set of capabilities this VOL connector claims.
///
/// This associated function mirrors the trait method and can be used in
/// tests without constructing a live MPI universe.
pub fn expected_capabilities() -> Vec<VolCapability> {
vec![
VolCapability::ReadData,
VolCapability::WriteData,
VolCapability::ListObjects,
VolCapability::ChunkedStorage,
VolCapability::ParallelIO,
]
}
/// Returns the MPI rank within COMM_WORLD (0-based).
///
/// Returns 0 when MPI is not available.
pub fn rank(&self) -> Rank {
#[cfg(feature = "mpi-io")]
{
self.universe.world().rank()
}
#[cfg(not(feature = "mpi-io"))]
{
0
}
}
/// Returns the total number of MPI processes.
///
/// Returns 1 when MPI is not available.
pub fn size(&self) -> Rank {
#[cfg(feature = "mpi-io")]
{
self.universe.world().size()
}
#[cfg(not(feature = "mpi-io"))]
{
1
}
}
}
#[allow(unused_variables)]
impl VirtualObjectLayer for MpiVol {
fn name(&self) -> &str {
"mpi-io"
}
fn capabilities(&self) -> Vec<VolCapability> {
vec![
VolCapability::ReadData,
VolCapability::WriteData,
VolCapability::ListObjects,
VolCapability::ChunkedStorage,
VolCapability::ParallelIO,
]
}
fn open(&mut self, location: &str) -> Result<(), VolError> {
self.location = Some(location.to_string());
Ok(())
}
fn close(&mut self) -> Result<(), VolError> {
self.location = None;
Ok(())
}
fn read_dataset(&self, path: &str) -> Result<Vec<u8>, VolError> {
let _loc = self.location.as_deref().ok_or_else(|| {
VolError::Io(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"file not open",
))
})?;
#[cfg(feature = "mpi-io")]
{
mpi_collective_read(self, _loc, path)
}
#[cfg(not(feature = "mpi-io"))]
{
Err(VolError::Unsupported("mpi-io feature not enabled".into()))
}
}
fn write_dataset(
&mut self,
path: &str,
data: &[u8],
shape: &[u64],
dtype: &str,
) -> Result<(), VolError> {
let _loc = self.location.as_deref().ok_or_else(|| {
VolError::Io(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"file not open",
))
})?;
#[cfg(feature = "mpi-io")]
{
mpi_collective_write(self, _loc, path, data, shape, dtype)
}
#[cfg(not(feature = "mpi-io"))]
{
Err(VolError::Unsupported("mpi-io feature not enabled".into()))
}
}
}
/// Collective read: root reads the file, broadcasts the target dataset to all ranks.
#[cfg(feature = "mpi-io")]
fn mpi_collective_read(vol: &MpiVol, location: &str, path: &str) -> Result<Vec<u8>, VolError> {
use clawhdf5_format::{
data_layout::DataLayout, data_read::read_raw_data_full, dataspace::Dataspace,
datatype::Datatype, filter_pipeline::FilterPipeline, group_v2::resolve_path_any,
message_type::MessageType, object_header::ObjectHeader, signature::find_signature,
superblock::Superblock,
};
use mpi::traits::*;
let world = vol.universe.world();
let rank = world.rank();
let raw_data: Vec<u8>;
let mut len_buf = [0usize; 1];
if rank == 0 {
let bytes = std::fs::read(location).map_err(VolError::Io)?;
let sig = find_signature(&bytes).map_err(|e| VolError::DataError(e.to_string()))?;
let sb = Superblock::parse(&bytes, sig).map_err(|e| VolError::DataError(e.to_string()))?;
let addr = resolve_path_any(&bytes, &sb, path)
.map_err(|e| VolError::NotFound(format!("{path}: {e}")))?;
let oh = ObjectHeader::parse(&bytes, addr as usize, sb.offset_size, sb.length_size)
.map_err(|e| VolError::DataError(e.to_string()))?;
let dt = oh
.messages
.iter()
.find(|m| m.msg_type == MessageType::Datatype)
.ok_or_else(|| VolError::DataError("no datatype".into()))?;
let (datatype, _) =
Datatype::parse(&dt.data).map_err(|e| VolError::DataError(e.to_string()))?;
let ds = oh
.messages
.iter()
.find(|m| m.msg_type == MessageType::Dataspace)
.ok_or_else(|| VolError::DataError("no dataspace".into()))?;
let dataspace = Dataspace::parse(&ds.data, sb.length_size)
.map_err(|e| VolError::DataError(e.to_string()))?;
let dl = oh
.messages
.iter()
.find(|m| m.msg_type == MessageType::DataLayout)
.ok_or_else(|| VolError::DataError("no data layout".into()))?;
let layout = DataLayout::parse(&dl.data, sb.offset_size, sb.length_size)
.map_err(|e| VolError::DataError(e.to_string()))?;
let pipeline = oh
.messages
.iter()
.find(|m| m.msg_type == MessageType::FilterPipeline)
.and_then(|m| FilterPipeline::parse(&m.data).ok());
raw_data = read_raw_data_full(
&bytes,
&layout,
&dataspace,
&datatype,
pipeline.as_ref(),
sb.offset_size,
sb.length_size,
)
.map_err(|e| VolError::DataError(e.to_string()))?;
len_buf[0] = raw_data.len();
} else {
raw_data = Vec::new();
}
// Broadcast length then data
world.process_at_rank(0).broadcast_into(&mut len_buf);
let mut result = vec![0u8; len_buf[0]];
if rank == 0 {
result.copy_from_slice(&raw_data);
}
world.process_at_rank(0).broadcast_into(&mut result);
Ok(result)
}
/// Collective write: rank 0 accumulates all contributions and writes atomically.
///
/// In a real parallel workload each rank provides its own data shard for a
/// different hyperslab. Here we demonstrate the pattern: all ranks send their
/// data to rank 0 which stitches and writes.
#[cfg(feature = "mpi-io")]
fn mpi_collective_write(
vol: &MpiVol,
location: &str,
path: &str,
data: &[u8],
shape: &[u64],
dtype: &str,
) -> Result<(), VolError> {
use clawhdf5_format::file_writer::FileWriter as FmtWriter;
use mpi::traits::*;
let world = vol.universe.world();
let size = world.size() as usize;
// Each rank sends its data length to root
let local_len = data.len();
let mut all_lens = if world.rank() == 0 {
vec![0usize; size]
} else {
Vec::new()
};
world
.process_at_rank(0)
.gather_into_root(&local_len, &mut all_lens);
// Root collects all contributions and writes
if world.rank() == 0 {
let total: usize = all_lens.iter().sum();
let mut merged = Vec::with_capacity(total);
// Rank 0's own contribution first
merged.extend_from_slice(data);
// Receive from ranks 1..size
for r in 1..size as i32 {
let expected = all_lens[r as usize];
let mut buf = vec![0u8; expected];
world.process_at_rank(r).receive_into(&mut buf);
merged.extend_from_slice(&buf);
}
// Write merged data via FileWriter
let mut fw = FmtWriter::new();
match dtype {
"f64" => {
let values: Vec<f64> = merged
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.collect();
fw.create_dataset(path).with_f64_data(&values);
}
"f32" => {
let values: Vec<f32> = merged
.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.collect();
fw.create_dataset(path).with_f32_data(&values);
}
_ => {
return Err(VolError::Unsupported(format!(
"mpi-io write: unsupported dtype {dtype}"
)));
}
}
let bytes = fw
.finish()
.map_err(|e| VolError::DataError(e.to_string()))?;
std::fs::write(location, &bytes).map_err(VolError::Io)?;
} else {
// Non-root ranks send their data to root
world.process_at_rank(0).send(data);
}
// Barrier: all ranks wait until root finishes writing
world.barrier();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mpi_vol_no_feature_returns_unsupported() {
#[cfg(not(feature = "mpi-io"))]
{
let result = MpiVol::new_world();
assert!(
matches!(result, Err(VolError::Unsupported(_))),
"expected Unsupported error without mpi-io feature"
);
}
#[cfg(feature = "mpi-io")]
{
// With MPI enabled, new_world() may succeed if MPI is installed.
// Just verify it doesn't panic.
let _ = MpiVol::new_world();
}
}
#[test]
fn mpi_vol_capabilities_include_parallel_io() {
let caps = MpiVol::expected_capabilities();
assert!(
caps.contains(&VolCapability::ParallelIO),
"expected ParallelIO in {caps:?}"
);
assert!(caps.contains(&VolCapability::ReadData));
assert!(caps.contains(&VolCapability::WriteData));
}
#[test]
fn no_feature_error_contains_feature_name() {
#[cfg(not(feature = "mpi-io"))]
{
let e = MpiVol::new_world().unwrap_err();
assert!(
e.to_string().contains("mpi-io"),
"error should mention 'mpi-io': {e}"
);
}
#[cfg(feature = "mpi-io")]
{
// With mpi-io enabled this test is vacuous; the feature-off path
// is what we're documenting.
}
}
#[test]
#[cfg(feature = "mpi-io")]
fn collective_read_all_ranks_get_same_data() {
use crate::vol::VirtualObjectLayer;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("test.h5");
{
use clawhdf5_format::file_writer::FileWriter as FmtWriter;
let mut fw = FmtWriter::new();
fw.create_dataset("temperature")
.with_f64_data(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let bytes = fw.finish().unwrap();
std::fs::write(&path, &bytes).unwrap();
}
let mut vol = MpiVol::new_world().expect("MPI init failed");
vol.open(path.to_str().unwrap()).unwrap();
let data = vol.read_dataset("temperature").unwrap();
assert_eq!(
data.len(),
40,
"rank {} got {} bytes",
vol.rank(),
data.len()
);
let values: Vec<f64> = data
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.collect();
assert_eq!(
values,
vec![1.0, 2.0, 3.0, 4.0, 5.0],
"rank {} got wrong data",
vol.rank()
);
}
#[test]
#[cfg(feature = "mpi-io")]
fn collective_write_assembles_all_shards() {
use crate::vol::VirtualObjectLayer;
use mpi::traits::*;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("parallel_out.h5");
let mut vol = MpiVol::new_world().expect("MPI init failed");
vol.open(path.to_str().unwrap()).unwrap();
let world = vol.universe.world();
let rank = world.rank() as usize;
let shard = ((rank as f64) * 10.0f64).to_le_bytes().to_vec();
vol.write_dataset("values", &shard, &[world.size() as u64], "f64")
.unwrap();
let total_size = world.size() as usize;
if rank == 0 {
let bytes = std::fs::read(&path).unwrap();
use clawhdf5_format::{
data_layout::DataLayout, data_read::read_raw_data_full, dataspace::Dataspace,
datatype::Datatype, group_v2::resolve_path_any, message_type::MessageType,
object_header::ObjectHeader, signature::find_signature, superblock::Superblock,
};
let sig = find_signature(&bytes).unwrap();
let sb = Superblock::parse(&bytes, sig).unwrap();
let addr = resolve_path_any(&bytes, &sb, "values").unwrap();
let oh =
ObjectHeader::parse(&bytes, addr as usize, sb.offset_size, sb.length_size).unwrap();
let (dt, _) = Datatype::parse(
&oh.messages
.iter()
.find(|m| m.msg_type == MessageType::Datatype)
.unwrap()
.data,
)
.unwrap();
let ds = Dataspace::parse(
&oh.messages
.iter()
.find(|m| m.msg_type == MessageType::Dataspace)
.unwrap()
.data,
sb.length_size,
)
.unwrap();
let dl = DataLayout::parse(
&oh.messages
.iter()
.find(|m| m.msg_type == MessageType::DataLayout)
.unwrap()
.data,
sb.offset_size,
sb.length_size,
)
.unwrap();
let raw =
read_raw_data_full(&bytes, &dl, &ds, &dt, None, sb.offset_size, sb.length_size)
.unwrap();
assert_eq!(
raw.len(),
total_size * 8,
"expected {} f64 values",
total_size
);
let values: Vec<f64> = raw
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.collect();
for (i, &v) in values.iter().enumerate() {
assert!(
(v - (i as f64 * 10.0)).abs() < 1e-9,
"rank {i} shard wrong: got {v}"
);
}
}
world.barrier();
}
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "libaec-sys"
version = "0.1.0"
edition = "2024"
links = "aec"
[build-dependencies]
pkg-config = "0.3"
+12
View File
@@ -0,0 +1,12 @@
fn main() {
if pkg_config::Config::new()
.atleast_version("1.0")
.probe("libaec")
.is_ok()
{
return; // pkg-config found libaec and emitted the link directives
}
// libaec not found via pkg-config. Do not emit a link directive; the
// szip feature in clawhdf5-format gates all actual FFI calls, so the
// crate compiles and passes tests without libaec installed.
}
+36
View File
@@ -0,0 +1,36 @@
//! Raw FFI bindings to libaec (Adaptive Entropy Coding library).
//!
//! Provides the `aec_buffer_decode` convenience function for one-shot decompression.
// AEC flag constants matching aec.h
pub const AEC_DATA_PREPROCESS: u32 = 1; // NN preprocessing
pub const AEC_DATA_MSB: u32 = 2; // big-endian sample order
pub const AEC_RESTRICTED: u32 = 4; // restricted coding set
pub const AEC_ALLOW_K13: u32 = 8; // allow k=13 option
unsafe extern "C" {
/// One-shot decompression. Returns 0 on success.
///
/// # Safety
/// `src` must be valid for `src_len` bytes; `dst` must be valid for `*dst_len` bytes.
pub fn aec_buffer_decode(
src: *const u8,
src_len: usize,
dst: *mut u8,
dst_len: *mut usize,
bits_per_sample: u32,
block_size: u32,
flags: u32,
) -> i32;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constants_are_correct() {
assert_eq!(AEC_DATA_PREPROCESS, 1);
assert_eq!(AEC_DATA_MSB, 2);
}
}