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:
co-authored by
Claude Sonnet 4.6
parent
90bdd7cd13
commit
d6c4d4f111
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) --------------------------------------------------
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "libaec-sys"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
links = "aec"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
pkg-config = "0.3"
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user