Compare commits
53
Commits
v2.1.0
...
55959b4920
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55959b4920 | ||
|
|
b70d594c4f | ||
|
|
b9898c2a9c | ||
|
|
88195d1c33 | ||
|
|
6b1ea450f5 | ||
|
|
b1fc23e975 | ||
|
|
1347746973 | ||
|
|
c30ed0cda5 | ||
|
|
d8ef8785e2 | ||
|
|
e23e0358e0 | ||
|
|
2f9f73bf24 | ||
|
|
aa3e12f3ae | ||
|
|
d41e5ecfdd | ||
|
|
5701e8045d | ||
|
|
e82b8f56bd | ||
|
|
2ddb22897c | ||
|
|
3a1fcc5cb3 | ||
|
|
bf197b70e3 | ||
|
|
cb0b0e9df2 | ||
|
|
e91f7fc539 | ||
|
|
d6c4d4f111 | ||
|
|
90bdd7cd13 | ||
|
|
28a0dc3384 | ||
|
|
bae80d030b | ||
|
|
8534c7d204 | ||
|
|
0754afb7f2 | ||
|
|
0aab49f2f0 | ||
|
|
20ad16ab69 | ||
|
|
e0189cd5c4 | ||
|
|
4fa7e89a46 | ||
|
|
57adc88320 | ||
|
|
e6f0d8f161 | ||
|
|
98ccc69411 | ||
|
|
908af40282 | ||
|
|
a24fcb8be4 | ||
|
|
4b1f4e369a | ||
|
|
c99fb39ffd | ||
|
|
dbd683dcaf | ||
|
|
06a1ef5285 | ||
|
|
8c68b5de33 | ||
|
|
249841e232 | ||
|
|
bff039fa29 | ||
|
|
4a5ab1c584 | ||
|
|
9062b3fb53 | ||
|
|
ec7357de45 | ||
|
|
6ab42c2f07 | ||
|
|
19ca662975 | ||
|
|
bc3a3a977a | ||
|
|
a13ff51918 | ||
|
|
3ff501c8ef | ||
|
|
49a99a9a40 | ||
|
|
b9fac46ea5 | ||
|
|
f1762f82a7 |
@@ -0,0 +1,26 @@
|
|||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: rust:latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Cache cargo registry/target
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
- name: Install rustfmt & clippy components
|
||||||
|
run: rustup component add rustfmt clippy
|
||||||
|
- name: Install thumbv7em-none-eabihf target
|
||||||
|
run: rustup target add thumbv7em-none-eabihf
|
||||||
|
- name: Run CI script
|
||||||
|
run: bash scripts/ci-test.sh
|
||||||
+259
-4
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**System:** Intel i7-12650H (10C/16T, 4.7 GHz boost) · 32 GB DDR5 · Linux 6.8.0
|
**System:** Intel i7-12650H (10C/16T, 4.7 GHz boost) · 32 GB DDR5 · Linux 6.8.0
|
||||||
**Rust:** 1.96.0-nightly (2026-03-14) · `--release` profile
|
**Rust:** 1.96.0-nightly (2026-03-14) · `--release` profile
|
||||||
**Date:** 2026-03-20
|
**Date:** 2026-07-01
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -114,8 +114,8 @@ HDF5 persistence with optional Write-Ahead Log.
|
|||||||
|
|
||||||
| Operation | Latency | Notes |
|
| Operation | Latency | Notes |
|
||||||
|-----------|---------|-------|
|
|-----------|---------|-------|
|
||||||
| Single save (no WAL) | 91 µs | Direct HDF5 write |
|
| Single save (no WAL) | 61 µs | Direct HDF5 write (owned-Vec IO path) |
|
||||||
| Single save (with WAL) | 134 µs | +47% for crash safety |
|
| Single save (with WAL) | 18 µs | WAL group-commit append; HDF5 write batched at flush |
|
||||||
| Batch 100 | 723 µs | 7.2 µs per record |
|
| Batch 100 | 723 µs | 7.2 µs per record |
|
||||||
| Batch 1,000 | 6.17 ms | 6.2 µs per record |
|
| Batch 1,000 | 6.17 ms | 6.2 µs per record |
|
||||||
| WAL save (1K existing) | 539 µs | Incremental append |
|
| WAL save (1K existing) | 539 µs | Incremental append |
|
||||||
@@ -160,7 +160,7 @@ End-to-end strategy evaluation including embedding operations.
|
|||||||
| **Hybrid vector+keyword** | <200 µs | 1K records |
|
| **Hybrid vector+keyword** | <200 µs | 1K records |
|
||||||
| **Knowledge graph query** | <25 µs | 1K entities |
|
| **Knowledge graph query** | <25 µs | 1K entities |
|
||||||
| **Temporal range query** | <1 µs | 10K timestamps |
|
| **Temporal range query** | <1 µs | 10K timestamps |
|
||||||
| **Memory write** | <135 µs | Per record |
|
| **Memory write** | <20 µs | Per record (WAL group-commit append) |
|
||||||
| **Consolidation cycle** | <165 µs | 1K records |
|
| **Consolidation cycle** | <165 µs | 1K records |
|
||||||
| **Importance gate** | <1 µs | Per record |
|
| **Importance gate** | <1 µs | Per record |
|
||||||
|
|
||||||
@@ -394,3 +394,258 @@ cargo run --release --bin footprint_bench
|
|||||||
cargo run --release --bin consolidation_efficiency
|
cargo run --release --bin consolidation_efficiency
|
||||||
cargo run --release --bin ephemeral_perf
|
cargo run --release --bin ephemeral_perf
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## h5bench-Equivalent I/O Benchmarks
|
||||||
|
|
||||||
|
Criterion harness mirroring h5bench serial workloads. clawhdf5 benchmarks dated 2026-07-01;
|
||||||
|
libhdf5 1.14.6 head-to-head comparison dated 2026-06-30 (same hardware, same Criterion harness).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo bench -p clawhdf5-bench # clawhdf5-only
|
||||||
|
cargo bench -p clawhdf5-bench --features libhdf5-compare # head-to-head
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sequential Read Throughput
|
||||||
|
|
||||||
|
Both read a 1-D contiguous f32 dataset. clawhdf5 parses from `Vec<u8>` (zero-copy);
|
||||||
|
libhdf5 reads from a temp file including `open` + `read` + `close` overhead.
|
||||||
|
|
||||||
|
| Workload | n=1K | n=10K | n=100K |
|
||||||
|
|----------|------|-------|--------|
|
||||||
|
| **clawhdf5** f32 | 634 ns / **5.9 GiB/s** | 2.44 µs / **15.3 GiB/s** | 24.5 µs / **15.2 GiB/s** |
|
||||||
|
| libhdf5 f32 | 45.2 µs / 85 MiB/s | 47.8 µs / 799 MiB/s | 73.9 µs / 5.0 GiB/s |
|
||||||
|
| **Speedup** | **71×** | **20×** | **3.0×** |
|
||||||
|
| clawhdf5 f64 | 743 ns / **10.0 GiB/s** | 4.17 µs / **17.8 GiB/s** | 43.3 µs / **17.2 GiB/s** |
|
||||||
|
| clawhdf5 from_disk (f64, OS I/O) | — | 10.1 µs / **7.4 GiB/s** | 77.6 µs / **9.6 GiB/s** |
|
||||||
|
| clawhdf5 hyperslab (f64, 10% slice) | — | 4.09 µs / **1.8 GiB/s** | 50.1 µs / **1.5 GiB/s** |
|
||||||
|
|
||||||
|
libhdf5 f64 comparison excluded — clawhdf5's datatype encoding differs from libhdf5's (known
|
||||||
|
gap), making cross-format reads unreliable for comparison.
|
||||||
|
|
||||||
|
### Chunked Read Throughput
|
||||||
|
|
||||||
|
| Matrix size | Latency | Throughput |
|
||||||
|
|-------------|---------|-----------|
|
||||||
|
| 64×64 f32 | 6.39 µs | **2.4 GiB/s** |
|
||||||
|
| 256×256 f32 | 41.7 µs | **5.9 GiB/s** |
|
||||||
|
| 512×512 f32 | 176 µs | **5.5 GiB/s** |
|
||||||
|
|
||||||
|
### Sequential Write Throughput
|
||||||
|
|
||||||
|
Both write to disk. At 100K elements both converge on the OS `write()` syscall ceiling.
|
||||||
|
|
||||||
|
| Workload | n=1K | n=10K | n=100K |
|
||||||
|
|----------|------|-------|--------|
|
||||||
|
| **clawhdf5** f32 | 9.44 µs / **404 MiB/s** | 25 µs / **1.49 GiB/s** | 228 µs / **1.63 GiB/s** |
|
||||||
|
| libhdf5 f32 | 77.9 µs / 49 MiB/s | 87.8 µs / 435 MiB/s | 214 µs / 1.74 GiB/s |
|
||||||
|
| **Speedup** | **8.2×** | **3.5×** | **≈ tie** |
|
||||||
|
| clawhdf5 f64 embeddings | 6.50 µs (n=128) | 8.67 µs (n=512) / **450 MiB/s** | 10.27 µs (n=1K) / **761 MiB/s** |
|
||||||
|
|
||||||
|
### Chunked Write: Codec Comparison (with auto-shuffle)
|
||||||
|
|
||||||
|
Auto-shuffle is applied before all compression codecs by default — AoS→SoA byte transpose,
|
||||||
|
implements byte-grouping pre-filter per arXiv:2506.18062. Shuffle dramatically improves
|
||||||
|
throughput for float/int data by creating long runs of similar bytes.
|
||||||
|
|
||||||
|
| Matrix size | Zstd-3 + shuffle | Deflate-6 + shuffle | Speedup |
|
||||||
|
|-------------|-----------------|---------------------|---------|
|
||||||
|
| 32×32 f32 | 48 µs / **81 MiB/s** | 39 µs / **100 MiB/s** | Deflate 1.23× faster (small chunk) |
|
||||||
|
| 128×128 f32 | **148 µs / 422 MiB/s** | 153 µs / **407 MiB/s** | Parity |
|
||||||
|
| 512×512 f32 | **1.34 ms / 748 MiB/s** | 1.39 ms / **719 MiB/s** | Zstd 1.04× faster |
|
||||||
|
|
||||||
|
Impact of auto-shuffle vs no-shuffle baseline:
|
||||||
|
|
||||||
|
| Matrix size | Zstd-3 speedup | Deflate-6 speedup |
|
||||||
|
|-------------|----------------|-------------------|
|
||||||
|
| 32×32 | +19% | +38% |
|
||||||
|
| 128×128 | +25% | **+204%** |
|
||||||
|
| 512×512 | +25% | **+157%** |
|
||||||
|
|
||||||
|
Both codecs perform at parity at large sizes (~720–750 MiB/s). Use `.with_zstd(3)` or
|
||||||
|
`.with_deflate(6)` for write-heavy workloads. Use `.without_shuffle()` only for byte arrays
|
||||||
|
or data that doesn't benefit from AoS→SoA transposition.
|
||||||
|
|
||||||
|
### Chunked Write vs libhdf5 (deflate-6)
|
||||||
|
|
||||||
|
clawhdf5 compresses all chunks in memory and issues a single `write()`. libhdf5 flushes each
|
||||||
|
chunk individually via its Virtual File Layer (one `pwrite()` per chunk).
|
||||||
|
|
||||||
|
| Matrix | clawhdf5 deflate-6 + shuffle | libhdf5 deflate-6 | Speedup |
|
||||||
|
|--------|------------------------------|-------------------|---------|
|
||||||
|
| 32×32 f32 | 39 µs / 100 MiB/s | 172 µs / 23 MiB/s | **4.4×** |
|
||||||
|
| 128×128 f32 | 153 µs / 407 MiB/s | 3,150 µs / 20 MiB/s | **20.6×** |
|
||||||
|
| 512×512 f32 | 1,390 µs / 719 MiB/s | 53,300 µs / 19 MiB/s | **38.4×** |
|
||||||
|
|
||||||
|
The 32×32 speedup (4.4×) is lower than the 512×512 speedup (38.4×) because shuffle adds
|
||||||
|
overhead that dominates at 4 KB chunks. libhdf5 was benchmarked without shuffle. The speedup
|
||||||
|
compounds with matrix size because libhdf5's per-chunk VFL overhead is proportional to chunk
|
||||||
|
count while clawhdf5's single-pass cost is constant.
|
||||||
|
|
||||||
|
### Codec Comparison: Pcodec vs Zstd-3
|
||||||
|
|
||||||
|
Pcodec (arXiv:2502.06112) is a pure-Rust lossless numerical codec with 30–94% better compression
|
||||||
|
ratio than Zstd for f32/f64 columns. Both sides benchmarked **without** auto-shuffle here (shuffle
|
||||||
|
degrades Pcodec which handles byte organization internally; Zstd-3 without shuffle numbers shown
|
||||||
|
for an apples-to-apples comparison).
|
||||||
|
|
||||||
|
| Matrix size | Pcodec | Zstd-3 (no shuffle) | Winner |
|
||||||
|
|-------------|--------|---------------------|--------|
|
||||||
|
| 32×32 f32 | 95 µs / **41 MiB/s** | 57 µs / **68 MiB/s** | Zstd-3 (1.66×) |
|
||||||
|
| 128×128 f32 | 528 µs / **118 MiB/s** | 179 µs / **349 MiB/s** | Zstd-3 (2.95×) |
|
||||||
|
| 512×512 f32 | 1.69 ms / **591 MiB/s** | 1.64 ms / **610 MiB/s** | Parity (3% diff) |
|
||||||
|
|
||||||
|
Pcodec's fixed per-chunk distributional analysis overhead (~400 µs) dominates at 32×32 (4 KB).
|
||||||
|
At 512×512 (1 MB) the speeds converge. **Pcodec's advantage is compression ratio, not encode
|
||||||
|
speed** — less data on disk means faster reads and lower storage cost. Enable with
|
||||||
|
`.with_pcodec()` for write-once/read-many workloads (embedding archives, scientific datasets).
|
||||||
|
|
||||||
|
### Metadata Throughput
|
||||||
|
|
||||||
|
clawhdf5 accumulates all metadata in memory and serializes in one pass. libhdf5 acquires a
|
||||||
|
global file mutex and flushes to disk on every attribute write or group creation.
|
||||||
|
|
||||||
|
**Attributes and datasets** (k = attribute or dataset count):
|
||||||
|
|
||||||
|
| Workload | k=4 | k=16 | k=64 | k=128 |
|
||||||
|
|----------|-----|------|------|-------|
|
||||||
|
| **clawhdf5** attrs_write (i64) | 8.05 µs / 494 Kop/s | 17.2 µs / 932 Kop/s | 49.2 µs / 1.30 Mop/s | 87.3 µs / 1.47 Mop/s |
|
||||||
|
| libhdf5 attrs_write | 100 µs / 40 Kop/s | 170 µs / 94 Kop/s | 472 µs / 136 Kop/s | 929 µs / 138 Kop/s |
|
||||||
|
| **Speedup** | **12.4×** | **9.9×** | **9.6×** | **10.6×** |
|
||||||
|
| clawhdf5 attrs_read | 1.06 µs / 3.78 Mop/s | 3.64 µs / 4.39 Mop/s | 15.7 µs / 4.08 Mop/s | 31.3 µs / 4.09 Mop/s |
|
||||||
|
| clawhdf5 string_attrs (write+read) | 5.17 µs / 774 Kop/s | 16.5 µs / 967 Kop/s | 33.6 µs / 951 Kop/s | — |
|
||||||
|
| clawhdf5 multi_dataset_write | 10.1 µs / 397 Kop/s | 31.5 µs / 508 Kop/s | 104 µs / 614 Kop/s | — |
|
||||||
|
|
||||||
|
**Groups** (k = group count):
|
||||||
|
|
||||||
|
| Workload | k=4 | k=16 | k=32 | k=64 |
|
||||||
|
|----------|-----|------|------|------|
|
||||||
|
| **clawhdf5** groups_create | 12.1 µs / 330 Kop/s | 33.7 µs / 475 Kop/s | 66.7 µs / 480 Kop/s | 121 µs / 529 Kop/s |
|
||||||
|
| libhdf5 groups_create | 140 µs / 28 Kop/s | 433 µs / 37 Kop/s | 690 µs / 46 Kop/s | 1,340 µs / 48 Kop/s |
|
||||||
|
| **Speedup** | **11.6×** | **12.8×** | **9.5×** | **11.1×** |
|
||||||
|
| clawhdf5 groups_traverse | 664 ns / 6.0 Mop/s | 3.55 µs / 4.5 Mop/s | 4.87 µs / 6.6 Mop/s | 10.6 µs / 6.0 Mop/s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## vs libhdf5 Summary
|
||||||
|
|
||||||
|
| Workload | clawhdf5 | libhdf5 | Speedup |
|
||||||
|
|----------|----------|---------|---------|
|
||||||
|
| Sequential read, 1K f32 | 634 ns | 45.2 µs | **71×** |
|
||||||
|
| Sequential read, 100K f32 | 24.5 µs · 15.2 GiB/s | 73.9 µs · 5.0 GiB/s | **3.0×** |
|
||||||
|
| Sequential write, 100K f32 | 228 µs · 1.63 GiB/s | 214 µs · 1.74 GiB/s | **≈ tie** |
|
||||||
|
| Chunked write deflate-6, 512×512 | 1,390 µs · 719 MiB/s | 53,300 µs · 19 MiB/s | **38.4×** |
|
||||||
|
| Attribute write, 128 attrs | 87.3 µs · 1.47 Mop/s | 929 µs · 138 Kop/s | **10.6×** |
|
||||||
|
| Group create, 64 groups | 121 µs · 529 Kop/s | 1,340 µs · 48 Kop/s | **11.1×** |
|
||||||
|
|
||||||
|
### Why the Gaps
|
||||||
|
|
||||||
|
**Metadata (10–13×):** libhdf5 was designed for MPI parallel filesystems where every metadata
|
||||||
|
write must be immediately visible to other processes. It acquires a global file mutex and
|
||||||
|
flushes to disk per operation. clawhdf5 builds the entire file in memory and writes it in one
|
||||||
|
shot — no locking, no flushing, no C heap allocation per message.
|
||||||
|
|
||||||
|
**Chunked compressed write (4–38×):** libhdf5 writes each chunk individually through its VFL
|
||||||
|
(Virtual File Layer), one `pwrite()` per chunk. clawhdf5 compresses all chunks in memory (Rayon
|
||||||
|
parallel when > 2 chunks), lays them out contiguously, and issues a single `write()`. The
|
||||||
|
speedup compounds with matrix size: libhdf5's per-chunk overhead is proportional to chunk count
|
||||||
|
while clawhdf5's architectural cost is constant.
|
||||||
|
|
||||||
|
**Small reads (20–71×):** libhdf5's per-open overhead (chunk cache init, SWMR lock, metadata
|
||||||
|
read) dominates at sub-millisecond payloads. clawhdf5 has no global state — `File::from_bytes()`
|
||||||
|
starts parsing immediately.
|
||||||
|
|
||||||
|
**Large contiguous writes (≈ tie at 100K):** Both are bottlenecked by the OS `write()` syscall
|
||||||
|
to the page cache. There is no algorithmic headroom above ~1.7 GiB/s on this hardware.
|
||||||
|
|
||||||
|
### Caveats
|
||||||
|
|
||||||
|
- libhdf5 f64 read comparison excluded — clawhdf5's f32 datatype encoding differs from libhdf5's (known compatibility gap). f64 results are clawhdf5-only.
|
||||||
|
- Serial benchmarks. clawhdf5 uses Rayon for chunk compression when > 2 chunks; that parallelism is already reflected in the chunked write numbers.
|
||||||
|
- clawhdf5 reads from `Vec<u8>` (zero-copy from mmap in production); libhdf5 reads from a temp file. This gives clawhdf5 a structural read advantage that reflects realistic API usage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Independent Validation: tank (Ryzen 7 7800X3D), 2026-08-03
|
||||||
|
|
||||||
|
The `vs libhdf5 Summary` numbers above were re-run on a second, independently
|
||||||
|
administered machine (`tank`: AMD Ryzen 7 7800X3D, 8C/16T, Ubuntu 26.04, libhdf5
|
||||||
|
1.14.6 via `apt`) to confirm they reproduce off the original i7-12650H box, and to
|
||||||
|
add benchmark coverage for two claims that a documentation review found were not
|
||||||
|
traceable to any dated benchmark run (see git history around 2026-08-03 for context).
|
||||||
|
This section documents both.
|
||||||
|
|
||||||
|
### Reproduction of the vs-libhdf5 Summary table
|
||||||
|
|
||||||
|
| Workload | clawhdf5 (tank) | libhdf5 (tank) | Speedup (tank) | Speedup (i7-12650H, above) |
|
||||||
|
|----------|-----------------|-----------------|----------------|------------------------------|
|
||||||
|
| Sequential read, 1K f32 | 553 ns | 44.2 µs | **79.9×** | 71× |
|
||||||
|
| Sequential read, 100K f32 | 23.3 µs | 63.6 µs | **2.7×** | 3.0× |
|
||||||
|
| Sequential write, 100K f32 | 210 µs | 189 µs | **≈ tie** (clawhdf5 ~11% behind) | ≈ tie (clawhdf5 ~7% behind) |
|
||||||
|
| Chunked write deflate-6, 512×512 | 1.44 ms | 65.0 ms | **45.3×** | 38.4× |
|
||||||
|
| Attribute write, 128 attrs | 85.2 µs | 877 µs | **10.3×** | 10.6× |
|
||||||
|
| Group create, 64 groups | 130 µs | 1.37 ms | **10.6×** | 11.1× |
|
||||||
|
|
||||||
|
Five of six rows land within ~15% of the original i7-12650H figures — consistent
|
||||||
|
with normal cross-machine variance, not a methodology artifact. The chunked-write
|
||||||
|
row moved further (38.4× → 45.3×, +18%): tank's libhdf5 per-chunk write cost scales
|
||||||
|
worse relative to its own sequential-write throughput than on the i7, likely IPC/
|
||||||
|
memory-subsystem dependent. Both figures are real and dated; we report both rather
|
||||||
|
than picking one.
|
||||||
|
|
||||||
|
### New coverage: replacing the retracted "metadata parse / 308×" and "zero-copy mmap / 313 ns" claims
|
||||||
|
|
||||||
|
An earlier README revision cited `19 ns` vs `2,080 µs` (labeled, incorrectly, `308×`)
|
||||||
|
for "metadata parse," and `313 ns` for "zero-copy mmap" — neither figure traced to
|
||||||
|
any benchmark in this file. Both have been retracted from the README. In their
|
||||||
|
place, two new Criterion benchmarks were added
|
||||||
|
(`crates/clawhdf5-bench/benches/h5bench_meta.rs`,
|
||||||
|
`crates/clawhdf5-bench/benches/h5bench_read.rs`) and run on tank:
|
||||||
|
|
||||||
|
**`metadata_open_from_disk`** — opens a small file from disk (`std::fs::read` /
|
||||||
|
`hdf5::File::open`) and resolves one attribute. Both sides pay real OS I/O, unlike
|
||||||
|
the retracted claim.
|
||||||
|
|
||||||
|
| Operation | clawhdf5 | libhdf5 | Speedup |
|
||||||
|
|-----------|----------|---------|---------|
|
||||||
|
| Open file + read 1 attribute | 4.01 µs | 39.3 µs | **9.8×** |
|
||||||
|
|
||||||
|
**`metadata_parse_in_memory`** (clawhdf5-only) — times `File::from_bytes()` alone,
|
||||||
|
given bytes already resident in memory, i.e. header-parse cost with disk I/O
|
||||||
|
excluded. There is no fair libhdf5-side equivalent (its API has no "parse from an
|
||||||
|
in-memory buffer, skip the OS open" path), so this is reported standalone rather
|
||||||
|
than as a speedup multiple — this is the honest version of what the old `19 ns`
|
||||||
|
number was trying to claim.
|
||||||
|
|
||||||
|
| Operation | clawhdf5 (in-memory, no I/O) |
|
||||||
|
|-----------|------------------------------|
|
||||||
|
| Parse superblock + resolve 1 attribute | 549 ns |
|
||||||
|
|
||||||
|
**`read_zerocopy_mmap`** — opens via `MmapFile` and reads an f64 dataset through
|
||||||
|
`read_f64_zerocopy()`, summing every element to force the mapped pages to actually
|
||||||
|
fault in (returning only a slice length, as an earlier draft of this benchmark did,
|
||||||
|
would repeat the exact "measures nothing" mistake being fixed here).
|
||||||
|
|
||||||
|
| n (f64 elements) | clawhdf5 mmap (zerocopy, page-fault-forced) | clawhdf5 (`Vec<u8>` copy) | libhdf5 (disk open + copy) |
|
||||||
|
|-------------------|----------------------------------------------|----------------------------|------------------------------|
|
||||||
|
| 1,000 | 7.86 µs | 4.50 µs | 44.2 µs |
|
||||||
|
| 10,000 | 19.0 µs | 9.53 µs | 47.1 µs |
|
||||||
|
| 100,000 | 112 µs | 72.0 µs | 81.2 µs |
|
||||||
|
|
||||||
|
Honest result: at these sizes, forcing full materialization through the mmap path
|
||||||
|
is **not** faster than the plain `Vec<u8>` copy path — `mmap()`/page-fault overhead
|
||||||
|
per call outweighs the copy it avoids. This contradicts the retracted `313 ns`
|
||||||
|
claim outright and is a genuinely useful finding: `MmapFile`'s real advantage is
|
||||||
|
avoiding the allocation/copy for large files or sparse access patterns (lower peak
|
||||||
|
RSS, share pages across processes), not raw single-shot read latency at these
|
||||||
|
sizes. No README claim is made from this row; it's recorded here for the record
|
||||||
|
and to keep future readers from reintroducing the old number.
|
||||||
|
|
||||||
|
**Reproduce:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo bench -p clawhdf5-bench --features libhdf5-compare --bench h5bench_meta -- metadata_open_from_disk
|
||||||
|
cargo bench -p clawhdf5-bench --features libhdf5-compare --bench h5bench_meta -- metadata_parse_in_memory
|
||||||
|
cargo bench -p clawhdf5-bench --features libhdf5-compare --bench h5bench_read -- read_zerocopy_mmap
|
||||||
|
```
|
||||||
|
|||||||
+176
@@ -1,5 +1,181 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- `clawhdf5-migrate`: substantial engine improvements:
|
||||||
|
- **Real content validation** — the post-migration check now reads the written
|
||||||
|
HDF5 back and compares actual content (chunk text, embeddings, and every
|
||||||
|
session/entity/relation field) against the source, not just row counts. A
|
||||||
|
representative sample of chunk rows is verified by default; `--validate-full`
|
||||||
|
checks every row. A corrupt migration that preserves counts no longer passes.
|
||||||
|
- **Configurable schema** — table names are no longer hardcoded; queries are
|
||||||
|
built from a `SchemaConfig` (table + ordered column names, defaulting to the
|
||||||
|
ZeroClaw layout) with `--chunks-table` / `--sessions-table` /
|
||||||
|
`--entities-table` / `--relations-table` overrides.
|
||||||
|
- **Streaming count pass** — `--dry-run` now does a `COUNT(*)`-only pass per
|
||||||
|
table instead of loading every row into memory.
|
||||||
|
- **Incremental migration** — `--incremental` reads the existing output, reads
|
||||||
|
only source chunks newer than the last migrated id, and appends them
|
||||||
|
(refreshing the metadata groups), instead of re-migrating everything.
|
||||||
|
- `clawhdf5-format`: read **IEEE-754 half-precision (f16)** floats. `read_as_f32`
|
||||||
|
/ `read_as_f64` previously only handled 4- and 8-byte floats; 2-byte floats
|
||||||
|
(e.g. float16-stored embeddings) now decode via a no_std-safe bit conversion.
|
||||||
|
- `clawhdf5-format`: **write multi-block fractal heaps** (root indirect block).
|
||||||
|
Dense attribute and dense link storage previously capped at a single direct
|
||||||
|
block (~64 KiB of heap data — a few thousand attributes/links). When the
|
||||||
|
objects exceed one direct block, the heap now lays out a root indirect block
|
||||||
|
(FHIB) over multiple direct blocks sized by the doubling table, distributing
|
||||||
|
objects across blocks with correct per-block heap offsets. Validated
|
||||||
|
end-to-end: a 2,500-attribute object and a 2,500-link group round-trip
|
||||||
|
through our reader and are read correctly by h5py. (Objects still may not
|
||||||
|
span a block — no huge-object path.)
|
||||||
|
- `clawhdf5-format`: **write dense group link storage** (fractal heap + v2
|
||||||
|
B-tree). A group with more than 8 links (libhdf5's compact `max_compact`
|
||||||
|
default) is now written densely — its links live in a fractal heap indexed by
|
||||||
|
a v2 B-tree of type 5 (link-name index) referenced from the group's LinkInfo
|
||||||
|
message — instead of as inline Link messages. This matches libhdf5's
|
||||||
|
compact→dense switchover and keeps large groups out of the object header.
|
||||||
|
Reverse-engineered against libhdf5: link heaps use `heap_id_length` 7 /
|
||||||
|
`max_heap_size` 32 (vs 8 / 40 for attributes). The shared single-direct-block
|
||||||
|
fractal-heap builder is now parameterized and used by both dense attributes
|
||||||
|
and dense links. Validated end-to-end: our reader round-trips, and h5py reads
|
||||||
|
the dense groups we write. (Single direct block — up to ~a couple thousand
|
||||||
|
links per group; beyond that needs indirect blocks, still unsupported.)
|
||||||
|
|
||||||
|
### Robustness
|
||||||
|
- `clawhdf5-format`: harden the readers added this cycle against malformed /
|
||||||
|
hostile input — they parse untrusted bytes and must return errors, never
|
||||||
|
panic, OOM, or recurse without bound. Fixed concrete vectors found by audit
|
||||||
|
and locked in with adversarial tests:
|
||||||
|
- **Paged Fixed Array**: `1 << max_nelmts_bits` shift overflow (a `u8` ≥ 64);
|
||||||
|
element/page offset multiplications now checked; element count bounded by
|
||||||
|
file size.
|
||||||
|
- **H5S selection decoder**: `ALL`/`NONE` no longer claim 16 bytes they don't
|
||||||
|
have; hyperslab `rank` capped at 32 (`H5S_MAX_RANK`) to stop a giant
|
||||||
|
allocation; `iter_linear` coordinate/stride/product arithmetic is checked.
|
||||||
|
- **VDS mapping parser**: no pre-allocation from the untrusted `nused`; all
|
||||||
|
selection slicing is bounds-checked.
|
||||||
|
- **scale-offset / N-Bit filters**: `1 << minbits` overflow at `minbits == 64`;
|
||||||
|
N-Bit `bit_offset + precision` overflow; N-Bit type-tree recursion depth
|
||||||
|
capped (no stack overflow from a crafted nested tree); element counts
|
||||||
|
bounded by the chunk's expected decompressed size so a bogus count can't
|
||||||
|
drive a huge allocation.
|
||||||
|
- **Virtual Dataset assembly**: a virtual dataset whose source is itself
|
||||||
|
virtual (a cycle) now errors instead of recursing into a stack overflow.
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- `clawhdf5-agent`: **compress fixed-length string datasets** (memory text
|
||||||
|
chunks, session summaries, ids, tags, entity/relation names, …). These were
|
||||||
|
always stored uncompressed with a "chunked compound not yet supported" note
|
||||||
|
that was simply stale — chunked writes work for fixed-size string/compound
|
||||||
|
datatypes like any other. `write_string_dataset` now chunks + deflates a
|
||||||
|
string dataset once its payload reaches 4 KiB, so large, highly-redundant
|
||||||
|
NullPad content shrinks substantially while tiny metadata stays contiguous
|
||||||
|
(no chunk-overhead bloat).
|
||||||
|
- `clawhdf5-format`: decode the **scale-offset filter** (id 6) — both the
|
||||||
|
integer variant (`H5Z_SO_INT`) and the floating-point **D-scale** variant
|
||||||
|
(`H5Z_SO_FLOAT_DSCALE`). Handles signed/unsigned int sizes, f32/f64, negative
|
||||||
|
minima, decimal scale factors and fill values; reverse-engineered against
|
||||||
|
HDF5 2.0 and validated end-to-end. The float E-scale variant remains
|
||||||
|
unsupported.
|
||||||
|
- `clawhdf5-format`: decode the **N-Bit filter** (id 5) — atomic, **compound**
|
||||||
|
and **array** layouts (the full recursive type tree, nestable to any depth),
|
||||||
|
previously unsupported. Signed and unsigned reduced-precision integers and
|
||||||
|
float members all read end-to-end, validated against HDF5 2.0.
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- `clawhdf5` / `clawhdf5-format`: read **external-file Virtual Datasets (VDS)**.
|
||||||
|
The format layer gains `read_raw_data_full_with_resolver` and a
|
||||||
|
`VdsSourceResolver` callback (`Fn(&str) -> Option<Vec<u8>>`) that maps a
|
||||||
|
stored source file name to its bytes, so the pure-byte reader can pull in
|
||||||
|
external sources without a filesystem of its own. The `clawhdf5` `File` API
|
||||||
|
wires a default resolver that reads sibling source files relative to the
|
||||||
|
opened file's directory, so `File::open(...).dataset(...).read_*()` now
|
||||||
|
transparently assembles cross-file VDS. A source file the resolver cannot
|
||||||
|
supply leaves its region at the fill value (matching HDF5); an external
|
||||||
|
source with no resolver at all is a clean error. In-memory files
|
||||||
|
(`File::from_bytes`) have no directory, so only same-file VDS resolves there.
|
||||||
|
- `clawhdf5-format`: assemble **same-file Virtual Datasets (VDS)** of any rank.
|
||||||
|
Previously a virtual layout returned `UnsupportedVersion`. The reader now
|
||||||
|
decodes the global-heap mapping block (reverse-engineered against HDF5 2.0:
|
||||||
|
`version · nused · [source-file · source-dataset · source-selection ·
|
||||||
|
virtual-selection]* · checksum`, including the block-version-1 same-file
|
||||||
|
marker), decodes the `H5S` source/virtual dataspace **selections** (ALL,
|
||||||
|
NONE, and version-3 regular hyperslabs), reads each same-file source dataset,
|
||||||
|
and scatters its selected elements into the virtual buffer in row-major order
|
||||||
|
(so multi-dimensional block mappings land correctly); unmapped regions are
|
||||||
|
left at the zero fill value. External-file sources return a clean unsupported
|
||||||
|
error. The previous `parse_vds_mappings` used a guessed layout that did not
|
||||||
|
match real files and is replaced.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `clawhdf5-format`: regression test for **scale-offset float E-scale**
|
||||||
|
datasets. The HDF5 library does not implement E-scale encoding — when asked
|
||||||
|
for it (`cd_values[0] = 1`) it stores the chunk raw and sets the chunk filter
|
||||||
|
mask to skip the filter — so these files read back verbatim purely by
|
||||||
|
honoring the per-chunk filter mask. The test locks in that behavior against a
|
||||||
|
fixture produced via the HDF5 low-level API; no E-scale decoder is needed.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- `clawhdf5-format`: **read multi-direct-block fractal heaps**. The reader split
|
||||||
|
direct vs indirect block rows using the FRHP "Starting # of Rows in Root
|
||||||
|
Indirect Block" field (a constant, typically 1), so any heap whose data spans
|
||||||
|
more than one direct block — common in libhdf5 files with a large group or
|
||||||
|
many dense attributes — was misread as having indirect blocks and failed with
|
||||||
|
`InvalidFractalHeapSignature`. The split is now derived from the heap geometry
|
||||||
|
(`max_direct_rows = log2(max_direct / start) + 2`). Validated against an
|
||||||
|
h5py-written 400-dense-attribute group (root indirect block, 4 rows, 13 direct
|
||||||
|
blocks).
|
||||||
|
- `clawhdf5-format`: scope the per-file **chunk cache by dataset**. The shared
|
||||||
|
`ChunkCache` built its chunk index once and reused it for every chunked
|
||||||
|
dataset in the file, keyed only by chunk coordinate with no dataset
|
||||||
|
discrimination. With a single chunked dataset per file this was latent; once a
|
||||||
|
file holds two chunked datasets of different rank (e.g. a 1-D compressed
|
||||||
|
string array and the 2-D embeddings matrix), the first dataset's index was
|
||||||
|
reused for the second, panicking with an out-of-bounds chunk coordinate. The
|
||||||
|
cache now rebinds (dropping its index, chunk-index map, layout, and
|
||||||
|
decompressed slots) whenever the dataset being read changes, while still
|
||||||
|
caching repeated/sequential access to the same dataset.
|
||||||
|
- `clawhdf5-format`: read **paged Fixed Array** chunk indexes. A filtered,
|
||||||
|
fixed-dimension dataset with more than one data-block page (>1024 chunks by
|
||||||
|
default) previously failed with "paged Fixed Array data blocks not yet
|
||||||
|
supported". The reader now walks the page-init bitmap (MSB-first), skips
|
||||||
|
uninitialized pages, and resolves each page's fixed full-size slot (including
|
||||||
|
the short final page). Reverse-engineered and validated end-to-end against an
|
||||||
|
HDF5 2.0 file.
|
||||||
|
- `clawhdf5-format`: read **array-typed datatypes** (e.g. an array-typed
|
||||||
|
compound member) via `read_as_i32/i64/u64/f32/f64` — previously a
|
||||||
|
`TypeMismatch`. The array is read as a flat sequence of its base elements
|
||||||
|
(recursing for nested arrays), applying base-type precision rules.
|
||||||
|
- `clawhdf5-format`: **sign-extend reduced-precision fixed-point integers** on
|
||||||
|
read. A signed integer whose datatype precision is smaller than its storage
|
||||||
|
size is stored zero-filled, so e.g. a 16-bit-precision `-1` previously read as
|
||||||
|
`65535`. The integer read paths now extract the precision field and
|
||||||
|
sign-extend (full-width types are unchanged). Completes signed N-Bit reads and
|
||||||
|
also fixes un-filtered reduced-precision integer datasets.
|
||||||
|
- `clawhdf5-format`: read datasets written by modern HDF5 (1.14+/2.0, i.e.
|
||||||
|
`libver=latest`). Compound (class 6) and array (class 10) datatype **version 5**
|
||||||
|
messages and data layout **version 5** messages were rejected as invalid; they
|
||||||
|
reuse the v3/v4 binary structure, so they are now accepted. This unblocks
|
||||||
|
reading compound types and — critically — every chunked/compressed dataset
|
||||||
|
written by HDF5 2.0. Found by running the h5py interop tests against
|
||||||
|
h5py 3.16 / HDF5 2.0.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- `clawhdf5-format`: chunked writes now compress all chunks up front via
|
||||||
|
`compress_all_chunks`, running across rayon threads under the `parallel`
|
||||||
|
feature when there are more than 4 filtered chunks. On-disk layout is
|
||||||
|
unchanged. Speeds up compressed embedding writes in `clawhdf5-agent` (which
|
||||||
|
enables `parallel`).
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Fix stale package names across all 13 per-crate READMEs (`rustyhdf5-*` /
|
||||||
|
`edgehdf5-*` → `clawhdf5-*`, usage versions → 2.1.0).
|
||||||
|
- Correct README workspace/test/crate stats and the CLAUDE.md CLI subcommand
|
||||||
|
list; document the `hnsw` and format compression/checksum feature flags and
|
||||||
|
the `entity_extract` / `async_memory` modules.
|
||||||
|
|
||||||
## v2.1.0 (2026-06-03)
|
## v2.1.0 (2026-06-03)
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ Pure-Rust HDF5 format implementation with HNSW vector search, WAL-backed persist
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Cargo workspace with 17 crates under `crates/`:
|
Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal FFI bindings crate for the optional `szip` feature):
|
||||||
|
|
||||||
| Crate | Role |
|
| Crate | Role |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| `clawhdf5-types` | Shared type definitions and physical constants |
|
| `clawhdf5-format` | HDF5 binary spec parser (superblock, B-tree, heap) — also holds shared type definitions and physical constants |
|
||||||
| `clawhdf5-format` | HDF5 binary spec parser (superblock, B-tree, heap) |
|
|
||||||
| `clawhdf5-io` | Read/write implementation |
|
| `clawhdf5-io` | Read/write implementation |
|
||||||
| `clawhdf5-filters` | Compression filters (gzip, LZ4, Zstd, Blosc) |
|
| `clawhdf5-filters` | Compression filters (gzip, LZ4, Zstd, Blosc) |
|
||||||
| `clawhdf5-derive` | Proc-macro derive for HDF5-serializable structs |
|
| `clawhdf5-derive` | Proc-macro derive for HDF5-serializable structs |
|
||||||
@@ -54,7 +53,7 @@ cargo test --workspace
|
|||||||
### CLI
|
### CLI
|
||||||
```bash
|
```bash
|
||||||
cargo run -p clawhdf5-cli -- --help
|
cargo run -p clawhdf5-cli -- --help
|
||||||
# inspect, dump, index, search subcommands
|
# create, save, search, recall, stats, flush-wal, agents-md, export, snapshot subcommands
|
||||||
```
|
```
|
||||||
|
|
||||||
### Python bindings
|
### Python bindings
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"crates/clawhdf5-format",
|
"crates/clawhdf5-format",
|
||||||
"crates/clawhdf5-types",
|
|
||||||
"crates/clawhdf5-io",
|
"crates/clawhdf5-io",
|
||||||
"crates/clawhdf5-filters",
|
"crates/clawhdf5-filters",
|
||||||
"crates/clawhdf5-derive",
|
"crates/clawhdf5-derive",
|
||||||
@@ -17,6 +16,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"
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,19 @@
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://www.rust-lang.org)
|
[](https://www.rust-lang.org)
|
||||||
[](#benchmarks)
|
[](#benchmarks)
|
||||||
[](BENCHMARKS.md#longmemeval-results)
|
[](BENCHMARKS.md#longmemeval-results)
|
||||||
[](BENCHMARKS.md#memory-footprint)
|
[](BENCHMARKS.md#memory-footprint)
|
||||||
|
|
||||||
ClawhDF5 is a pure-Rust HDF5 implementation combined with a research-grade agent memory engine. It gives AI agents persistent, searchable, cryptographically verifiable memory — all stored in a single portable file.
|
ClawHDF5 is a pure-Rust HDF5 implementation combined with a research-grade agent memory engine. It gives AI agents persistent, searchable, cryptographically verifiable memory — all stored in a single portable file.
|
||||||
|
|
||||||
|
> **Two things live here:**
|
||||||
|
> - **A general-purpose, pure-Rust HDF5 library** — zero C dependencies, NetCDF-4 support, SIMD/GPU acceleration. See the **[Crate Map](#crate-map)** and **[BENCHMARKS.md](BENCHMARKS.md)** for the libhdf5 head-to-head numbers.
|
||||||
|
> - **An agent memory layer built on top of it** — vector search, knowledge graph, hippocampal-style consolidation, in `clawhdf5-agent`.
|
||||||
|
|
||||||
```
|
```
|
||||||
cargo add clawhdf5-agent --features agent
|
cargo add clawhdf5 # core HDF5 read/write, no agent layer
|
||||||
|
cargo add clawhdf5-agent --features agent # + agent memory layer
|
||||||
```
|
```
|
||||||
|
|
||||||
> **New here?** Start with the **[Quickstart Guide](docs/QUICKSTART.md)** · See **[Use Cases](docs/USE_CASES.md)** · Read **[Benchmarks](BENCHMARKS.md)**
|
> **New here?** Start with the **[Quickstart Guide](docs/QUICKSTART.md)** · See **[Use Cases](docs/USE_CASES.md)** · Read **[Benchmarks](BENCHMARKS.md)**
|
||||||
@@ -37,7 +42,21 @@ Every AI agent needs memory. Today that means scattered Markdown files, SQLite d
|
|||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
Benchmarked on Intel i7-12650H (10C/16T), 384-dim embeddings, Criterion.rs.
|
Vector search and agent-memory operations below are benchmarked on Intel i7-12650H (10C/16T), 384-dim embeddings, Criterion.rs. The HDF5 Core I/O table immediately below is from a separate, independently reproduced run (see its own hardware note).
|
||||||
|
|
||||||
|
### HDF5 Core I/O (vs libhdf5 1.14.6)
|
||||||
|
|
||||||
|
*Benchmark numbers are being validated in collaboration with engineers from the HDF5 Group to confirm methodology and reproducibility.*
|
||||||
|
|
||||||
|
Figures below are from an independent reproduction run on a second machine (AMD Ryzen 7 7800X3D, 2026-08-03). Full methodology, the original i7-12650H run, and two additional benchmarks added to close prior coverage gaps (an I/O-inclusive metadata-open comparison and an honest zero-copy-mmap measurement) are in [BENCHMARKS.md § Independent Validation](BENCHMARKS.md#independent-validation-tank-ryzen-7-7800x3d-2026-08-03).
|
||||||
|
|
||||||
|
| Operation | ClawhDF5 | libhdf5 | Speedup |
|
||||||
|
|-----------|----------|---------|---------|
|
||||||
|
| Attribute write (128 attrs) | 85.2 µs | 877 µs | **10.3×** |
|
||||||
|
| Group create (64 groups) | 130 µs | 1.37 ms | **10.6×** |
|
||||||
|
| Chunked write, deflate-6 (512×512 f32) | 1.44 ms | 65.0 ms | **45.3×** |
|
||||||
|
| Sequential read (100K f32) | 23.3 µs | 63.6 µs | **2.7×** |
|
||||||
|
| Sequential write (100K f32) | 210 µs | 189 µs | **≈ tie** |
|
||||||
|
|
||||||
### Vector Search
|
### Vector Search
|
||||||
|
|
||||||
@@ -57,17 +76,21 @@ Benchmarked on Intel i7-12650H (10C/16T), 384-dim embeddings, Criterion.rs.
|
|||||||
| Spreading activation | **17 µs** | 100 entities |
|
| Spreading activation | **17 µs** | 100 entities |
|
||||||
| Temporal range query | **716 ns** | 10K timestamps |
|
| Temporal range query | **716 ns** | 10K timestamps |
|
||||||
| Consolidation cycle | **164 µs** | 1K records |
|
| Consolidation cycle | **164 µs** | 1K records |
|
||||||
| Memory write (WAL) | **134 µs** | per record |
|
| Memory write (WAL) | **18 µs** | per record (group-commit append; HDF5 batched at flush) |
|
||||||
| Importance gate | **61 ns** | per record |
|
| Importance gate | **61 ns** | per record |
|
||||||
|
|
||||||
### HDF5 Core I/O (vs h5py/C HDF5)
|
### Chunked Write Throughput (codec comparison)
|
||||||
|
|
||||||
| Operation | ClawhDF5 | h5py (C) | Speedup |
|
Measured with Criterion on f32 matrices. Auto-shuffle is applied before all compression codecs
|
||||||
|-----------|----------|----------|---------|
|
by default (AoS→SoA byte transpose, +157–204% throughput for float data):
|
||||||
| Metadata parse | 19 ns | 2,080 µs | **308×** |
|
|
||||||
| Write 1M f64 | 0.82 ms | 1.60 ms | **2×** |
|
| Codec | 128×128 f32 | 512×512 f32 | Notes |
|
||||||
| Read 1M f64 | 0.28 ms | 0.65 ms | **2.3×** |
|
|-------|-------------|-------------|-------|
|
||||||
| Zero-copy mmap | 313 ns | N/A | — |
|
| Zstd level 3 | **148 µs / 422 MiB/s** | **1.34 ms / 748 MiB/s** | With auto-shuffle |
|
||||||
|
| Deflate level 6 | 153 µs / 407 MiB/s | 1.39 ms / 719 MiB/s | With auto-shuffle |
|
||||||
|
| Pcodec | 528 µs / 118 MiB/s | 1.69 ms / 591 MiB/s | Best compression ratio |
|
||||||
|
|
||||||
|
Use `.with_zstd(3)` or `.with_deflate(6)` for write-heavy workloads — both now perform at ~720–750 MiB/s on large matrices. Use `.with_pcodec()` for write-once/read-many workloads where compression ratio matters more than encode speed. Disable auto-shuffle with `.without_shuffle()` for byte arrays that don't benefit from AoS→SoA transposition.
|
||||||
|
|
||||||
> ¹ MemX ([arxiv:2603.16171](https://arxiv.org/abs/2603.16171), March 2026): Rust + libSQL, claims <90ms at 100K records.
|
> ¹ MemX ([arxiv:2603.16171](https://arxiv.org/abs/2603.16171), March 2026): Rust + libSQL, claims <90ms at 100K records.
|
||||||
|
|
||||||
@@ -170,9 +193,11 @@ ClawhDF5's agent memory engine implements research from 15+ recent papers on age
|
|||||||
| **`vector_search`** | Flat cosine, pre-normed, SIMD, BLAS, GPU, parallel search paths |
|
| **`vector_search`** | Flat cosine, pre-normed, SIMD, BLAS, GPU, parallel search paths |
|
||||||
| **`ivf` / `pq`** | IVF-PQ approximate nearest neighbor for billion-scale search |
|
| **`ivf` / `pq`** | IVF-PQ approximate nearest neighbor for billion-scale search |
|
||||||
| **`bm25`** | BM25 keyword index with TF-IDF scoring |
|
| **`bm25`** | BM25 keyword index with TF-IDF scoring |
|
||||||
|
| **`entity_extract`** | Rule-based entity extraction from text chunks into the knowledge graph |
|
||||||
| **`wal`** | Write-ahead log for crash-safe persistence |
|
| **`wal`** | Write-ahead log for crash-safe persistence |
|
||||||
| **`memory_strategy`** | Pluggable strategies: save-every, semantic-shift, user-correction detection |
|
| **`memory_strategy`** | Pluggable strategies: save-every, semantic-shift, user-correction detection |
|
||||||
| **`decision_gate`** | Sub-microsecond trivial/substantive classification |
|
| **`decision_gate`** | Sub-microsecond trivial/substantive classification |
|
||||||
|
| **`async_memory`** | Tokio-based async wrapper over the memory store (`async` feature) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -313,7 +338,7 @@ let exported = backend.export_markdown("MEMORY.md")?;
|
|||||||
## Crate Map
|
## Crate Map
|
||||||
|
|
||||||
```
|
```
|
||||||
clawhdf5 workspace (15 crates, 72K lines of Rust)
|
clawhdf5 workspace (17 crates, 84K lines of Rust)
|
||||||
│
|
│
|
||||||
├── Core HDF5
|
├── Core HDF5
|
||||||
│ ├── clawhdf5-types — Type system definitions
|
│ ├── clawhdf5-types — Type system definitions
|
||||||
@@ -327,14 +352,18 @@ clawhdf5 workspace (15 crates, 72K lines of Rust)
|
|||||||
│ └── clawhdf5-gpu — GPU compute (wgpu)
|
│ └── clawhdf5-gpu — GPU compute (wgpu)
|
||||||
│
|
│
|
||||||
├── Agent Memory
|
├── Agent Memory
|
||||||
│ ├── clawhdf5-agent — Memory engine (16.8K lines, 29 modules)
|
│ ├── clawhdf5-agent — Memory engine (20.7K lines, 32 modules)
|
||||||
│ ├── clawhdf5-ann — HNSW approximate nearest neighbor
|
│ ├── clawhdf5-ann — HNSW approximate nearest neighbor (default backend)
|
||||||
│ ├── clawhdf5-migrate — SQLite → HDF5 migration
|
│ ├── clawhdf5-migrate — SQLite → HDF5 migration
|
||||||
│ ├── clawhdf5-android — Android JNI bridge
|
│ ├── clawhdf5-android — Android JNI bridge
|
||||||
│ └── clawhdf5-cli — CLI tool
|
│ └── clawhdf5-cli — CLI tool
|
||||||
│
|
│
|
||||||
└── Bindings
|
├── Bindings
|
||||||
└── clawhdf5-py — Python (PyO3)
|
│ ├── clawhdf5-py — Python (PyO3)
|
||||||
|
│ └── clawhdf5-napi — Node.js (napi-rs)
|
||||||
|
│
|
||||||
|
└── Tooling
|
||||||
|
└── clawhdf5-bench — Benchmark suite
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -365,6 +394,7 @@ ClawhDF5's agent memory design draws from 15+ recent papers:
|
|||||||
|------|---------|-------------|
|
|------|---------|-------------|
|
||||||
| `agent` | no | Full agent memory layer |
|
| `agent` | no | Full agent memory layer |
|
||||||
| `float16` | **yes** | Half-precision embedding storage (2× compression) |
|
| `float16` | **yes** | Half-precision embedding storage (2× compression) |
|
||||||
|
| `hnsw` | **yes** | HNSW approximate vector index for `hybrid_search` (via `clawhdf5-ann`); disable for an exact linear scan |
|
||||||
| `parallel` | no | Rayon parallel search |
|
| `parallel` | no | Rayon parallel search |
|
||||||
| `fast-math` | no | BLAS matrix-vector multiply |
|
| `fast-math` | no | BLAS matrix-vector multiply |
|
||||||
| `accelerate` | no | Apple Accelerate / AMX (macOS) |
|
| `accelerate` | no | Apple Accelerate / AMX (macOS) |
|
||||||
@@ -380,7 +410,15 @@ ClawhDF5's agent memory design draws from 15+ recent papers:
|
|||||||
| `deflate` | yes | Deflate compression |
|
| `deflate` | yes | Deflate compression |
|
||||||
| `checksum` | yes | Jenkins lookup3 verification |
|
| `checksum` | yes | Jenkins lookup3 verification |
|
||||||
| `provenance` | yes | SHA-256 provenance attributes |
|
| `provenance` | yes | SHA-256 provenance attributes |
|
||||||
| `parallel` | no | Parallel chunk encoding (rayon) |
|
| `fast-deflate` | **yes** | zlib-ng backend for faster deflate |
|
||||||
|
| `system-zlib-decompress` | **yes** | Use the system zlib for decompression where available |
|
||||||
|
| `parallel` | no | Parallel chunk encoding + compression (rayon) |
|
||||||
|
| `fast-checksum` | no | crc32fast-accelerated checksums |
|
||||||
|
| `lz4` | no | LZ4 block compression filter (id 32004) |
|
||||||
|
| `zstd` | no | Zstandard compression filter (id 32015) |
|
||||||
|
| `pcodec` | no | Pcodec lossless numerical codec (id 32023, via `pco` crate) |
|
||||||
|
| `system-zlib` / `zlib-rs` | no | Alternative zlib backends for deflate |
|
||||||
|
| `blake3_hash` | no | BLAKE3 content hashing for provenance |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -401,7 +439,8 @@ cargo test --workspace # all 417+ tests
|
|||||||
cargo test -p clawhdf5-agent # agent memory tests
|
cargo test -p clawhdf5-agent # agent memory tests
|
||||||
|
|
||||||
# Benchmarks
|
# Benchmarks
|
||||||
cargo bench -p clawhdf5-agent # full benchmark suite
|
cargo bench -p clawhdf5-agent # agent memory suite
|
||||||
|
cargo bench -p clawhdf5-bench # h5bench-equivalent I/O suite
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+13
-5
@@ -151,12 +151,20 @@ All 8 tracks delivered. 1,546 tests passing, zero clippy warnings.
|
|||||||
|
|
||||||
## What's Next
|
## What's Next
|
||||||
|
|
||||||
- [ ] CI/CD pipeline — GitHub Actions or Gitea Actions for automated testing
|
Verified against current repo state on 2026-08-03 (see also `docs/superpowers/plans/` for the filter-codec/format-write/MPI-IO work, now shipped):
|
||||||
|
|
||||||
|
- [ ] CI/CD pipeline — still no GitHub/Gitea Actions workflow in the repo; automated testing is manual only
|
||||||
- [ ] Academic benchmark cross-validation — reproduce MemX/LongMemEval under identical conditions
|
- [ ] Academic benchmark cross-validation — reproduce MemX/LongMemEval under identical conditions
|
||||||
- [ ] TypeScript bridge — full npm package via `clawhdf5-napi` (scaffolding exists)
|
- [ ] TypeScript bridge — `clawhdf5-napi` has no `package.json`; it's still Rust-only scaffolding, not a publishable npm package
|
||||||
- [ ] Publish crates to crates.io
|
- [ ] Publish crates to crates.io — no `publish` config anywhere in the workspace yet
|
||||||
- [ ] Python wheel distribution via maturin for `clawhdf5-py`
|
- [ ] Python wheel distribution via maturin — `crates/clawhdf5-py/pyproject.toml` exists (maturin-buildable locally) but wheels aren't published anywhere
|
||||||
|
|
||||||
|
### Recently closed out (2026-08-03 cleanup pass)
|
||||||
|
|
||||||
|
- [x] Removed `clawhdf5-types` — it was an empty 1-line stub crate; shared type definitions already live in `clawhdf5-format`, so CLAUDE.md and the workspace manifest were corrected instead of filling it in
|
||||||
|
- [x] Superblock v4 (page-buffer mode) read/write — the only unimplemented task from `docs/superpowers/plans/2026-06-29-format-write-extensions.md`; now done (`Superblock::parse_v4`/`serialize`, `FileWriter::with_page_size`)
|
||||||
|
- [x] Reconciled the three `docs/superpowers/plans/*.md` docs against actual shipped code — they were pre-work plans for `d6c4d4f` (2026-06-30), committed to git late; checkboxes now reflect reality
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_Last updated: 2026-04-12_
|
_Last updated: 2026-08-03_
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# rustyhdf5-accel
|
# clawhdf5-accel
|
||||||
|
|
||||||
[](https://crates.io/crates/rustyhdf5-accel)
|
[](https://crates.io/crates/clawhdf5-accel)
|
||||||
[](https://docs.rs/rustyhdf5-accel)
|
[](https://docs.rs/clawhdf5-accel)
|
||||||
|
|
||||||
SIMD-accelerated operations for rustyhdf5.
|
SIMD-accelerated operations for clawhdf5.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ SIMD-accelerated operations for rustyhdf5.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use rustyhdf5_accel::checksum::crc32_simd;
|
use clawhdf5_accel::checksum::crc32_simd;
|
||||||
|
|
||||||
let crc = crc32_simd(&data);
|
let crc = crc32_simd(&data);
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,42 +13,44 @@ use std::arch::x86_64::*;
|
|||||||
/// Caller must verify is_x86_feature_detected!("avx512f").
|
/// Caller must verify is_x86_feature_detected!("avx512f").
|
||||||
// SAFETY: Caller must have verified avx512f via is_x86_feature_detected!.
|
// SAFETY: Caller must have verified avx512f via is_x86_feature_detected!.
|
||||||
#[target_feature(enable = "avx512f")]
|
#[target_feature(enable = "avx512f")]
|
||||||
pub unsafe fn dot_product(a: &[f32], b: &[f32]) -> f32 { unsafe {
|
pub unsafe fn dot_product(a: &[f32], b: &[f32]) -> f32 {
|
||||||
assert_eq!(a.len(), b.len());
|
unsafe {
|
||||||
let len = a.len();
|
assert_eq!(a.len(), b.len());
|
||||||
let mut i = 0;
|
let len = a.len();
|
||||||
let mut acc0 = _mm512_setzero_ps();
|
let mut i = 0;
|
||||||
let mut acc1 = _mm512_setzero_ps();
|
let mut acc0 = _mm512_setzero_ps();
|
||||||
|
let mut acc1 = _mm512_setzero_ps();
|
||||||
|
|
||||||
// Process 32 elements per iteration (2x16 unrolled)
|
// Process 32 elements per iteration (2x16 unrolled)
|
||||||
while i + 32 <= len {
|
while i + 32 <= len {
|
||||||
let va0 = _mm512_loadu_ps(a.as_ptr().add(i));
|
let va0 = _mm512_loadu_ps(a.as_ptr().add(i));
|
||||||
let vb0 = _mm512_loadu_ps(b.as_ptr().add(i));
|
let vb0 = _mm512_loadu_ps(b.as_ptr().add(i));
|
||||||
acc0 = _mm512_fmadd_ps(va0, vb0, acc0);
|
acc0 = _mm512_fmadd_ps(va0, vb0, acc0);
|
||||||
|
|
||||||
let va1 = _mm512_loadu_ps(a.as_ptr().add(i + 16));
|
let va1 = _mm512_loadu_ps(a.as_ptr().add(i + 16));
|
||||||
let vb1 = _mm512_loadu_ps(b.as_ptr().add(i + 16));
|
let vb1 = _mm512_loadu_ps(b.as_ptr().add(i + 16));
|
||||||
acc1 = _mm512_fmadd_ps(va1, vb1, acc1);
|
acc1 = _mm512_fmadd_ps(va1, vb1, acc1);
|
||||||
|
|
||||||
i += 32;
|
i += 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
if i + 16 <= len {
|
||||||
|
let va = _mm512_loadu_ps(a.as_ptr().add(i));
|
||||||
|
let vb = _mm512_loadu_ps(b.as_ptr().add(i));
|
||||||
|
acc0 = _mm512_fmadd_ps(va, vb, acc0);
|
||||||
|
i += 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sum = _mm512_reduce_add_ps(_mm512_add_ps(acc0, acc1));
|
||||||
|
|
||||||
|
while i < len {
|
||||||
|
sum += a[i] * b[i];
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
sum
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if i + 16 <= len {
|
|
||||||
let va = _mm512_loadu_ps(a.as_ptr().add(i));
|
|
||||||
let vb = _mm512_loadu_ps(b.as_ptr().add(i));
|
|
||||||
acc0 = _mm512_fmadd_ps(va, vb, acc0);
|
|
||||||
i += 16;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut sum = _mm512_reduce_add_ps(_mm512_add_ps(acc0, acc1));
|
|
||||||
|
|
||||||
while i < len {
|
|
||||||
sum += a[i] * b[i];
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
sum
|
|
||||||
}}
|
|
||||||
|
|
||||||
/// AVX-512 cosine similarity — fused single pass.
|
/// AVX-512 cosine similarity — fused single pass.
|
||||||
///
|
///
|
||||||
@@ -56,38 +58,40 @@ pub unsafe fn dot_product(a: &[f32], b: &[f32]) -> f32 { unsafe {
|
|||||||
/// Caller must verify is_x86_feature_detected!("avx512f").
|
/// Caller must verify is_x86_feature_detected!("avx512f").
|
||||||
// SAFETY: Caller must have verified avx512f via is_x86_feature_detected!.
|
// SAFETY: Caller must have verified avx512f via is_x86_feature_detected!.
|
||||||
#[target_feature(enable = "avx512f")]
|
#[target_feature(enable = "avx512f")]
|
||||||
pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { unsafe {
|
pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||||
assert_eq!(a.len(), b.len());
|
unsafe {
|
||||||
let len = a.len();
|
assert_eq!(a.len(), b.len());
|
||||||
let mut i = 0;
|
let len = a.len();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
let mut dot_acc = _mm512_setzero_ps();
|
let mut dot_acc = _mm512_setzero_ps();
|
||||||
let mut norm_a_acc = _mm512_setzero_ps();
|
let mut norm_a_acc = _mm512_setzero_ps();
|
||||||
let mut norm_b_acc = _mm512_setzero_ps();
|
let mut norm_b_acc = _mm512_setzero_ps();
|
||||||
|
|
||||||
while i + 16 <= len {
|
while i + 16 <= len {
|
||||||
let va = _mm512_loadu_ps(a.as_ptr().add(i));
|
let va = _mm512_loadu_ps(a.as_ptr().add(i));
|
||||||
let vb = _mm512_loadu_ps(b.as_ptr().add(i));
|
let vb = _mm512_loadu_ps(b.as_ptr().add(i));
|
||||||
dot_acc = _mm512_fmadd_ps(va, vb, dot_acc);
|
dot_acc = _mm512_fmadd_ps(va, vb, dot_acc);
|
||||||
norm_a_acc = _mm512_fmadd_ps(va, va, norm_a_acc);
|
norm_a_acc = _mm512_fmadd_ps(va, va, norm_a_acc);
|
||||||
norm_b_acc = _mm512_fmadd_ps(vb, vb, norm_b_acc);
|
norm_b_acc = _mm512_fmadd_ps(vb, vb, norm_b_acc);
|
||||||
i += 16;
|
i += 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut dot = _mm512_reduce_add_ps(dot_acc);
|
||||||
|
let mut norm_a = _mm512_reduce_add_ps(norm_a_acc);
|
||||||
|
let mut norm_b = _mm512_reduce_add_ps(norm_b_acc);
|
||||||
|
|
||||||
|
while i < len {
|
||||||
|
dot += a[i] * b[i];
|
||||||
|
norm_a += a[i] * a[i];
|
||||||
|
norm_b += b[i] * b[i];
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let denom = (norm_a * norm_b).sqrt();
|
||||||
|
if denom == 0.0 { 0.0 } else { dot / denom }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let mut dot = _mm512_reduce_add_ps(dot_acc);
|
|
||||||
let mut norm_a = _mm512_reduce_add_ps(norm_a_acc);
|
|
||||||
let mut norm_b = _mm512_reduce_add_ps(norm_b_acc);
|
|
||||||
|
|
||||||
while i < len {
|
|
||||||
dot += a[i] * b[i];
|
|
||||||
norm_a += a[i] * a[i];
|
|
||||||
norm_b += b[i] * b[i];
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let denom = (norm_a * norm_b).sqrt();
|
|
||||||
if denom == 0.0 { 0.0 } else { dot / denom }
|
|
||||||
}}
|
|
||||||
|
|
||||||
/// AVX-512 L2 distance.
|
/// AVX-512 L2 distance.
|
||||||
///
|
///
|
||||||
@@ -95,27 +99,29 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { unsafe {
|
|||||||
/// Caller must verify is_x86_feature_detected!("avx512f").
|
/// Caller must verify is_x86_feature_detected!("avx512f").
|
||||||
// SAFETY: Caller must have verified avx512f via is_x86_feature_detected!.
|
// SAFETY: Caller must have verified avx512f via is_x86_feature_detected!.
|
||||||
#[target_feature(enable = "avx512f")]
|
#[target_feature(enable = "avx512f")]
|
||||||
pub unsafe fn l2_distance(a: &[f32], b: &[f32]) -> f32 { unsafe {
|
pub unsafe fn l2_distance(a: &[f32], b: &[f32]) -> f32 {
|
||||||
assert_eq!(a.len(), b.len());
|
unsafe {
|
||||||
let len = a.len();
|
assert_eq!(a.len(), b.len());
|
||||||
let mut i = 0;
|
let len = a.len();
|
||||||
let mut acc = _mm512_setzero_ps();
|
let mut i = 0;
|
||||||
|
let mut acc = _mm512_setzero_ps();
|
||||||
|
|
||||||
while i + 16 <= len {
|
while i + 16 <= len {
|
||||||
let va = _mm512_loadu_ps(a.as_ptr().add(i));
|
let va = _mm512_loadu_ps(a.as_ptr().add(i));
|
||||||
let vb = _mm512_loadu_ps(b.as_ptr().add(i));
|
let vb = _mm512_loadu_ps(b.as_ptr().add(i));
|
||||||
let diff = _mm512_sub_ps(va, vb);
|
let diff = _mm512_sub_ps(va, vb);
|
||||||
acc = _mm512_fmadd_ps(diff, diff, acc);
|
acc = _mm512_fmadd_ps(diff, diff, acc);
|
||||||
i += 16;
|
i += 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sum = _mm512_reduce_add_ps(acc);
|
||||||
|
|
||||||
|
while i < len {
|
||||||
|
let d = a[i] - b[i];
|
||||||
|
sum += d * d;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
sum.sqrt()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let mut sum = _mm512_reduce_add_ps(acc);
|
|
||||||
|
|
||||||
while i < len {
|
|
||||||
let d = a[i] - b[i];
|
|
||||||
sum += d * d;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
sum.sqrt()
|
|
||||||
}}
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
# edgehdf5-memory
|
# clawhdf5-agent
|
||||||
|
|
||||||
[](https://crates.io/crates/edgehdf5-memory)
|
[](https://crates.io/crates/clawhdf5-agent)
|
||||||
[](https://docs.rs/edgehdf5-memory)
|
[](https://docs.rs/clawhdf5-agent)
|
||||||
|
|
||||||
HDF5-backed persistent memory store for on-device AI agents.
|
HDF5-backed persistent memory store for on-device AI agents.
|
||||||
|
|
||||||
Built on [rustyhdf5](https://crates.io/crates/rustyhdf5), edgehdf5-memory provides a vector-searchable memory backend optimized for edge AI workloads. Store embeddings, text chunks, and metadata in a single HDF5 file with SIMD-accelerated similarity search.
|
Built on [clawhdf5](https://crates.io/crates/clawhdf5), clawhdf5-agent provides a vector-searchable memory backend optimized for edge AI workloads. Store embeddings, text chunks, and metadata in a single HDF5 file with SIMD-accelerated similarity search.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Persistent vector store in HDF5 format
|
- Persistent vector store in HDF5 format
|
||||||
- Cosine similarity and L2 distance search
|
- Cosine similarity and L2 distance search
|
||||||
- SIMD-accelerated via rustyhdf5-accel (AVX2, NEON)
|
- SIMD-accelerated via clawhdf5-accel (AVX2, NEON)
|
||||||
- Optional GPU acceleration via rustyhdf5-gpu
|
- Optional GPU acceleration via clawhdf5-gpu
|
||||||
- Memory-mapped access for large stores
|
- Memory-mapped access for large stores
|
||||||
- f16 storage support for compact embeddings
|
- f16 storage support for compact embeddings
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ Built on [rustyhdf5](https://crates.io/crates/rustyhdf5), edgehdf5-memory provid
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
edgehdf5-memory = "1.93"
|
clawhdf5-agent = "2.1.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -116,14 +116,15 @@ impl GpuSearchBackend {
|
|||||||
|
|
||||||
// If we don't have an accelerator but now above threshold, try init
|
// If we don't have an accelerator but now above threshold, try init
|
||||||
if vectors.len() >= self.threshold
|
if vectors.len() >= self.threshold
|
||||||
&& let Ok(mut accel) = clawhdf5_gpu::GpuAccelerator::new() {
|
&& let Ok(mut accel) = clawhdf5_gpu::GpuAccelerator::new()
|
||||||
let flat: Vec<f32> = vectors.iter().flat_map(|v| v.iter().copied()).collect();
|
{
|
||||||
if accel.upload_vectors(&flat, self.dim).is_ok()
|
let flat: Vec<f32> = vectors.iter().flat_map(|v| v.iter().copied()).collect();
|
||||||
&& accel.upload_norms(norms).is_ok()
|
if accel.upload_vectors(&flat, self.dim).is_ok()
|
||||||
{
|
&& accel.upload_norms(norms).is_ok()
|
||||||
self.accelerator = Some(accel);
|
{
|
||||||
}
|
self.accelerator = Some(accel);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "gpu"))]
|
#[cfg(not(feature = "gpu"))]
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ fn build_memory_group(
|
|||||||
let mut group = builder.create_group("memory");
|
let mut group = builder.create_group("memory");
|
||||||
|
|
||||||
// chunks: fixed-length string array
|
// chunks: fixed-length string array
|
||||||
write_string_dataset(&mut group, "chunks", &cache.chunks, false);
|
write_string_dataset(&mut group, "chunks", &cache.chunks);
|
||||||
|
|
||||||
// embeddings: f32 [N x D]
|
// embeddings: f32 [N x D]
|
||||||
let n = cache.embeddings.len() as u64;
|
let n = cache.embeddings.len() as u64;
|
||||||
@@ -83,14 +83,15 @@ fn build_memory_group(
|
|||||||
let rows_per_chunk = (target_chunk_bytes / (d * 4)).max(1).min(n);
|
let rows_per_chunk = (target_chunk_bytes / (d * 4)).max(1).min(n);
|
||||||
ds.with_chunks(&[rows_per_chunk, d]);
|
ds.with_chunks(&[rows_per_chunk, d]);
|
||||||
|
|
||||||
// Compression: shuffle + deflate for embeddings when enabled
|
// Compression: Zstd for embeddings — faster than deflate at same ratio.
|
||||||
|
// Shuffle is applied automatically (auto-shuffle pre-filter).
|
||||||
if config.compression {
|
if config.compression {
|
||||||
let level = if config.compression_level > 0 {
|
let level = if config.compression_level > 0 {
|
||||||
config.compression_level
|
config.compression_level.min(22)
|
||||||
} else {
|
} else {
|
||||||
1 // fast default for embeddings
|
3 // Zstd level 3: fast + good ratio for f32 embeddings
|
||||||
};
|
};
|
||||||
ds.with_shuffle().with_deflate(level);
|
ds.with_zstd(level);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@ fn build_memory_group(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// source_channel: fixed-length string array
|
// source_channel: fixed-length string array
|
||||||
write_string_dataset(&mut group, "source_channel", &cache.source_channels, false);
|
write_string_dataset(&mut group, "source_channel", &cache.source_channels);
|
||||||
|
|
||||||
// timestamps: f64 array
|
// timestamps: f64 array
|
||||||
group
|
group
|
||||||
@@ -109,11 +110,11 @@ fn build_memory_group(
|
|||||||
.with_f64_data(&cache.timestamps)
|
.with_f64_data(&cache.timestamps)
|
||||||
.fill_time(FillTime::Never);
|
.fill_time(FillTime::Never);
|
||||||
|
|
||||||
// session_ids: fixed-length string array (no compression — chunked compound not yet supported)
|
// session_ids: fixed-length string array (auto-compressed when large)
|
||||||
write_string_dataset(&mut group, "session_ids", &cache.session_ids, false);
|
write_string_dataset(&mut group, "session_ids", &cache.session_ids);
|
||||||
|
|
||||||
// tags: fixed-length string array (no compression — chunked compound not yet supported)
|
// tags: fixed-length string array (auto-compressed when large)
|
||||||
write_string_dataset(&mut group, "tags", &cache.tags, false);
|
write_string_dataset(&mut group, "tags", &cache.tags);
|
||||||
|
|
||||||
// tombstones: u8 array — use compact if small
|
// tombstones: u8 array — use compact if small
|
||||||
{
|
{
|
||||||
@@ -150,7 +151,7 @@ fn build_sessions_group(
|
|||||||
let mut group = builder.create_group("sessions");
|
let mut group = builder.create_group("sessions");
|
||||||
|
|
||||||
let ids: Vec<String> = sessions.entries.iter().map(|e| e.id.clone()).collect();
|
let ids: Vec<String> = sessions.entries.iter().map(|e| e.id.clone()).collect();
|
||||||
write_string_dataset(&mut group, "ids", &ids, false);
|
write_string_dataset(&mut group, "ids", &ids);
|
||||||
|
|
||||||
let start_idxs: Vec<i64> = sessions
|
let start_idxs: Vec<i64> = sessions
|
||||||
.entries
|
.entries
|
||||||
@@ -165,14 +166,14 @@ fn build_sessions_group(
|
|||||||
group.create_dataset("end_idxs").with_i64_data(&end_idxs);
|
group.create_dataset("end_idxs").with_i64_data(&end_idxs);
|
||||||
|
|
||||||
let channels: Vec<String> = sessions.entries.iter().map(|e| e.channel.clone()).collect();
|
let channels: Vec<String> = sessions.entries.iter().map(|e| e.channel.clone()).collect();
|
||||||
write_string_dataset(&mut group, "channels", &channels, false);
|
write_string_dataset(&mut group, "channels", &channels);
|
||||||
|
|
||||||
let timestamps: Vec<f64> = sessions.entries.iter().map(|e| e.ts).collect();
|
let timestamps: Vec<f64> = sessions.entries.iter().map(|e| e.ts).collect();
|
||||||
group
|
group
|
||||||
.create_dataset("timestamps")
|
.create_dataset("timestamps")
|
||||||
.with_f64_data(×tamps);
|
.with_f64_data(×tamps);
|
||||||
|
|
||||||
write_string_dataset(&mut group, "summaries", &sessions.summaries, false);
|
write_string_dataset(&mut group, "summaries", &sessions.summaries);
|
||||||
|
|
||||||
let finished = group.finish();
|
let finished = group.finish();
|
||||||
builder.add_group(finished);
|
builder.add_group(finished);
|
||||||
@@ -192,14 +193,14 @@ fn build_knowledge_group(
|
|||||||
.with_i64_data(&entity_ids);
|
.with_i64_data(&entity_ids);
|
||||||
|
|
||||||
let entity_names: Vec<String> = knowledge.entities.iter().map(|e| e.name.clone()).collect();
|
let entity_names: Vec<String> = knowledge.entities.iter().map(|e| e.name.clone()).collect();
|
||||||
write_string_dataset(&mut group, "entity_names", &entity_names, false);
|
write_string_dataset(&mut group, "entity_names", &entity_names);
|
||||||
|
|
||||||
let entity_types: Vec<String> = knowledge
|
let entity_types: Vec<String> = knowledge
|
||||||
.entities
|
.entities
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| e.entity_type.clone())
|
.map(|e| e.entity_type.clone())
|
||||||
.collect();
|
.collect();
|
||||||
write_string_dataset(&mut group, "entity_types", &entity_types, false);
|
write_string_dataset(&mut group, "entity_types", &entity_types);
|
||||||
|
|
||||||
let emb_idxs: Vec<i64> = knowledge.entities.iter().map(|e| e.embedding_idx).collect();
|
let emb_idxs: Vec<i64> = knowledge.entities.iter().map(|e| e.embedding_idx).collect();
|
||||||
group
|
group
|
||||||
@@ -222,7 +223,7 @@ fn build_knowledge_group(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|r| r.relation.clone())
|
.map(|r| r.relation.clone())
|
||||||
.collect();
|
.collect();
|
||||||
write_string_dataset(&mut group, "relation_types", &rel_types, false);
|
write_string_dataset(&mut group, "relation_types", &rel_types);
|
||||||
|
|
||||||
let rel_weights: Vec<f32> = knowledge.relations.iter().map(|r| r.weight).collect();
|
let rel_weights: Vec<f32> = knowledge.relations.iter().map(|r| r.weight).collect();
|
||||||
group
|
group
|
||||||
@@ -234,7 +235,7 @@ fn build_knowledge_group(
|
|||||||
|
|
||||||
// Aliases
|
// Aliases
|
||||||
if !knowledge.alias_strings.is_empty() {
|
if !knowledge.alias_strings.is_empty() {
|
||||||
write_string_dataset(&mut group, "alias_strings", &knowledge.alias_strings, false);
|
write_string_dataset(&mut group, "alias_strings", &knowledge.alias_strings);
|
||||||
group
|
group
|
||||||
.create_dataset("alias_entity_ids")
|
.create_dataset("alias_entity_ids")
|
||||||
.with_i64_data(&knowledge.alias_entity_ids);
|
.with_i64_data(&knowledge.alias_entity_ids);
|
||||||
@@ -252,11 +253,15 @@ fn build_knowledge_group(
|
|||||||
///
|
///
|
||||||
/// When `compress` is true, uses chunked storage with deflate(6) —
|
/// When `compress` is true, uses chunked storage with deflate(6) —
|
||||||
/// NullPad strings have high redundancy and compress very well.
|
/// NullPad strings have high redundancy and compress very well.
|
||||||
|
/// Payload size (bytes) at or above which a fixed-length string dataset is
|
||||||
|
/// stored chunked + deflate-compressed. Below this, the chunk B-tree/heap
|
||||||
|
/// overhead outweighs the savings, so the data is left contiguous.
|
||||||
|
const STRING_COMPRESS_THRESHOLD: usize = 4096;
|
||||||
|
|
||||||
fn write_string_dataset(
|
fn write_string_dataset(
|
||||||
group: &mut clawhdf5_format::type_builders::GroupBuilder,
|
group: &mut clawhdf5_format::type_builders::GroupBuilder,
|
||||||
name: &str,
|
name: &str,
|
||||||
strings: &[String],
|
strings: &[String],
|
||||||
compress: bool,
|
|
||||||
) {
|
) {
|
||||||
if strings.is_empty() {
|
if strings.is_empty() {
|
||||||
// Empty dataset: use 1-byte string type with no data
|
// Empty dataset: use 1-byte string type with no data
|
||||||
@@ -278,6 +283,7 @@ fn write_string_dataset(
|
|||||||
bytes.resize(max_len, 0);
|
bytes.resize(max_len, 0);
|
||||||
raw.extend_from_slice(&bytes);
|
raw.extend_from_slice(&bytes);
|
||||||
}
|
}
|
||||||
|
let raw_len = raw.len();
|
||||||
|
|
||||||
let dtype = Datatype::String {
|
let dtype = Datatype::String {
|
||||||
size: max_len as u32,
|
size: max_len as u32,
|
||||||
@@ -288,9 +294,12 @@ fn write_string_dataset(
|
|||||||
.create_dataset(name)
|
.create_dataset(name)
|
||||||
.with_compound_data(dtype, raw, strings.len() as u64);
|
.with_compound_data(dtype, raw, strings.len() as u64);
|
||||||
|
|
||||||
// Deflate compression for string datasets — NullPad has high redundancy
|
// Fixed-length NullPad strings have high redundancy (padding + repeated
|
||||||
if compress && strings.len() > 1 {
|
// content), so deflate pays off once the payload is large enough to absorb
|
||||||
// Chunk size: target ~64KB chunks for string data
|
// the chunking overhead. Fixed-length string datasets are chunkable like
|
||||||
|
// any other fixed-size datatype.
|
||||||
|
if strings.len() > 1 && raw_len >= STRING_COMPRESS_THRESHOLD {
|
||||||
|
// Target ~64KB chunks for string data.
|
||||||
let elem_size = max_len as u64;
|
let elem_size = max_len as u64;
|
||||||
let target_chunk = 64 * 1024;
|
let target_chunk = 64 * 1024;
|
||||||
let rows_per_chunk = (target_chunk / elem_size).max(1).min(strings.len() as u64);
|
let rows_per_chunk = (target_chunk / elem_size).max(1).min(strings.len() as u64);
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ impl HDF5Memory {
|
|||||||
) -> Vec<(usize, f32)> {
|
) -> Vec<(usize, f32)> {
|
||||||
self.ensure_hnsw_fresh();
|
self.ensure_hnsw_fresh();
|
||||||
match self.hnsw.as_ref() {
|
match self.hnsw.as_ref() {
|
||||||
Some(index)
|
Some(index) if !index.is_empty() && index.dimension() == query_embedding.len() => {
|
||||||
if !index.is_empty() && index.dimension() == query_embedding.len() =>
|
|
||||||
{
|
|
||||||
// Over-fetch so the merge sees a useful vector pool; cosine
|
// Over-fetch so the merge sees a useful vector pool; cosine
|
||||||
// distance from the index converts back to similarity (1 - d).
|
// distance from the index converts back to similarity (1 - d).
|
||||||
let pool = (k * 8).max(64);
|
let pool = (k * 8).max(64);
|
||||||
@@ -38,7 +36,13 @@ impl HDF5Memory {
|
|||||||
.map(|(id, dist)| (id, 1.0 - dist))
|
.map(|(id, dist)| (id, 1.0 - dist))
|
||||||
.collect();
|
.collect();
|
||||||
let kw_scores = bm25.search(query_text, self.cache.len());
|
let kw_scores = bm25.search(query_text, self.cache.len());
|
||||||
hybrid::merge_vector_keyword(vec_scores, kw_scores, vector_weight, keyword_weight, k)
|
hybrid::merge_vector_keyword(
|
||||||
|
vec_scores,
|
||||||
|
kw_scores,
|
||||||
|
vector_weight,
|
||||||
|
keyword_weight,
|
||||||
|
k,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
_ => hybrid::hybrid_search(
|
_ => hybrid::hybrid_search(
|
||||||
query_embedding,
|
query_embedding,
|
||||||
|
|||||||
@@ -44,11 +44,20 @@ pub struct WalEntry {
|
|||||||
pub tombstone_index: Option<usize>,
|
pub tombstone_index: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many entries to accumulate before updating the header entry_count.
|
||||||
|
///
|
||||||
|
/// The header count is only needed for replay; `read_entries` already handles
|
||||||
|
/// stale counts by reading until EOF. Updating every N entries rather than
|
||||||
|
/// every entry eliminates 3 lseek() + 1 write() per entry — see arXiv:2507.13062.
|
||||||
|
const GROUP_COMMIT_SIZE: u32 = 8;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct WalFile {
|
pub struct WalFile {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
file: Option<File>,
|
file: Option<File>,
|
||||||
entry_count: u32,
|
entry_count: u32,
|
||||||
|
/// Entries written since the last header count update.
|
||||||
|
pending_header_sync: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WalFile {
|
impl WalFile {
|
||||||
@@ -83,6 +92,7 @@ impl WalFile {
|
|||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
file: Some(f),
|
file: Some(f),
|
||||||
entry_count,
|
entry_count,
|
||||||
|
pending_header_sync: 0,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Create new WAL
|
// Create new WAL
|
||||||
@@ -95,62 +105,82 @@ impl WalFile {
|
|||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
file: Some(f),
|
file: Some(f),
|
||||||
entry_count: 0,
|
entry_count: 0,
|
||||||
|
pending_header_sync: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append a save entry to the WAL.
|
/// Append a save entry to the WAL.
|
||||||
|
///
|
||||||
|
/// Serializes the entry into a single buffer before writing to minimize
|
||||||
|
/// syscall count (1 write() vs ~8 previously). The header entry_count is
|
||||||
|
/// updated every GROUP_COMMIT_SIZE entries rather than on every write,
|
||||||
|
/// eliminating 3 lseek() + 1 write() per entry (arXiv:2507.13062).
|
||||||
|
///
|
||||||
|
/// Crash safety: `read_entries` reads until EOF and handles stale header
|
||||||
|
/// counts, so deferred header updates do not compromise recovery.
|
||||||
pub fn append_save(&mut self, entry: &WalEntry) -> Result<(), MemoryError> {
|
pub fn append_save(&mut self, entry: &WalEntry) -> Result<(), MemoryError> {
|
||||||
|
let emb_len = entry.embedding.len();
|
||||||
|
let mut buf = Vec::with_capacity(
|
||||||
|
1 + 8 + // type + timestamp
|
||||||
|
4 + entry.chunk.len() +
|
||||||
|
4 + emb_len * 4 +
|
||||||
|
4 + entry.source_channel.len() +
|
||||||
|
4 + entry.session_id.len() +
|
||||||
|
4 + entry.tags.len(),
|
||||||
|
);
|
||||||
|
buf.push(WalEntryType::Save as u8);
|
||||||
|
buf.extend_from_slice(&entry.timestamp.to_le_bytes());
|
||||||
|
serialize_str(&mut buf, &entry.chunk);
|
||||||
|
buf.extend_from_slice(&(emb_len as u32).to_le_bytes());
|
||||||
|
for &val in &entry.embedding {
|
||||||
|
buf.extend_from_slice(&val.to_le_bytes());
|
||||||
|
}
|
||||||
|
serialize_str(&mut buf, &entry.source_channel);
|
||||||
|
serialize_str(&mut buf, &entry.session_id);
|
||||||
|
serialize_str(&mut buf, &entry.tags);
|
||||||
|
|
||||||
let f = self
|
let f = self
|
||||||
.file
|
.file
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
|
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
|
||||||
// entry_type
|
f.write_all(&buf)?;
|
||||||
f.write_all(&[WalEntryType::Save as u8])?;
|
|
||||||
// timestamp
|
|
||||||
f.write_all(&entry.timestamp.to_le_bytes())?;
|
|
||||||
// chunk
|
|
||||||
write_len_prefixed_str(f, &entry.chunk)?;
|
|
||||||
// embedding
|
|
||||||
let emb_len = entry.embedding.len() as u32;
|
|
||||||
f.write_all(&emb_len.to_le_bytes())?;
|
|
||||||
for &val in &entry.embedding {
|
|
||||||
f.write_all(&val.to_le_bytes())?;
|
|
||||||
}
|
|
||||||
// source_channel
|
|
||||||
write_len_prefixed_str(f, &entry.source_channel)?;
|
|
||||||
// session_id
|
|
||||||
write_len_prefixed_str(f, &entry.session_id)?;
|
|
||||||
// tags
|
|
||||||
write_len_prefixed_str(f, &entry.tags)?;
|
|
||||||
f.flush()?;
|
|
||||||
|
|
||||||
self.entry_count += 1;
|
self.entry_count += 1;
|
||||||
self.write_entry_count()?;
|
self.pending_header_sync += 1;
|
||||||
|
if self.pending_header_sync >= GROUP_COMMIT_SIZE {
|
||||||
|
self.write_entry_count()?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append a tombstone entry (deletion).
|
/// Append a tombstone entry (deletion).
|
||||||
pub fn append_tombstone(&mut self, index: usize, timestamp: f64) -> Result<(), MemoryError> {
|
pub fn append_tombstone(&mut self, index: usize, timestamp: f64) -> Result<(), MemoryError> {
|
||||||
|
let mut buf = [0u8; 1 + 8 + 4]; // type + timestamp + index
|
||||||
|
buf[0] = WalEntryType::Tombstone as u8;
|
||||||
|
buf[1..9].copy_from_slice(×tamp.to_le_bytes());
|
||||||
|
buf[9..13].copy_from_slice(&(index as u32).to_le_bytes());
|
||||||
|
|
||||||
let f = self
|
let f = self
|
||||||
.file
|
.file
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
|
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
|
||||||
f.write_all(&[WalEntryType::Tombstone as u8])?;
|
f.write_all(&buf)?;
|
||||||
f.write_all(×tamp.to_le_bytes())?;
|
|
||||||
f.write_all(&(index as u32).to_le_bytes())?;
|
|
||||||
f.flush()?;
|
|
||||||
|
|
||||||
self.entry_count += 1;
|
self.entry_count += 1;
|
||||||
self.write_entry_count()?;
|
self.pending_header_sync += 1;
|
||||||
|
if self.pending_header_sync >= GROUP_COMMIT_SIZE {
|
||||||
|
self.write_entry_count()?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read all entries from the WAL (for replay on open).
|
/// Read all entries from the WAL (for replay on open).
|
||||||
///
|
///
|
||||||
/// Tolerates truncated WAL files: if the file is shorter than the header's
|
/// Reads until EOF — the header `entry_count` is used only for pre-allocation
|
||||||
/// `entry_count` claims, the successfully-read entries are returned without
|
/// (and may be stale if written with deferred group-commit updates). This
|
||||||
/// error. This handles crash-during-truncate and header-only WAL scenarios.
|
/// tolerates both truncated files (crash mid-write) and stale header counts
|
||||||
|
/// (crash before the next group-commit header sync).
|
||||||
pub fn read_entries(path: &Path) -> Result<Vec<WalEntry>, MemoryError> {
|
pub fn read_entries(path: &Path) -> Result<Vec<WalEntry>, MemoryError> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -168,11 +198,12 @@ impl WalFile {
|
|||||||
header[4]
|
header[4]
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let entry_count = u32::from_le_bytes([header[5], header[6], header[7], header[8]]);
|
// entry_count is a pre-allocation hint only — we read until EOF.
|
||||||
let mut entries = Vec::with_capacity(entry_count as usize);
|
let entry_count_hint = u32::from_le_bytes([header[5], header[6], header[7], header[8]]);
|
||||||
|
let mut entries = Vec::with_capacity(entry_count_hint as usize);
|
||||||
|
|
||||||
for _ in 0..entry_count {
|
loop {
|
||||||
// Read entry type — EOF here means truncated WAL, not an error
|
// Read entry type — EOF here is normal end-of-log, not an error
|
||||||
let mut type_buf = [0u8; 1];
|
let mut type_buf = [0u8; 1];
|
||||||
if f.read_exact(&mut type_buf).is_err() {
|
if f.read_exact(&mut type_buf).is_err() {
|
||||||
break;
|
break;
|
||||||
@@ -252,6 +283,7 @@ impl WalFile {
|
|||||||
f.flush()?;
|
f.flush()?;
|
||||||
self.file = Some(f);
|
self.file = Some(f);
|
||||||
self.entry_count = 0;
|
self.entry_count = 0;
|
||||||
|
self.pending_header_sync = 0;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,8 +306,8 @@ impl WalFile {
|
|||||||
let pos = f.stream_position()?;
|
let pos = f.stream_position()?;
|
||||||
f.seek(SeekFrom::Start(5))?;
|
f.seek(SeekFrom::Start(5))?;
|
||||||
f.write_all(&self.entry_count.to_le_bytes())?;
|
f.write_all(&self.entry_count.to_le_bytes())?;
|
||||||
f.flush()?;
|
|
||||||
f.seek(SeekFrom::Start(pos))?;
|
f.seek(SeekFrom::Start(pos))?;
|
||||||
|
self.pending_header_sync = 0;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,11 +338,11 @@ pub fn replay_into_cache(entries: &[WalEntry], cache: &mut crate::cache::MemoryC
|
|||||||
|
|
||||||
// --- Binary helpers ---
|
// --- Binary helpers ---
|
||||||
|
|
||||||
fn write_len_prefixed_str(f: &mut File, s: &str) -> Result<(), MemoryError> {
|
/// Serialize a length-prefixed string into an in-memory buffer (zero syscalls).
|
||||||
|
fn serialize_str(buf: &mut Vec<u8>, s: &str) {
|
||||||
let bytes = s.as_bytes();
|
let bytes = s.as_bytes();
|
||||||
f.write_all(&(bytes.len() as u32).to_le_bytes())?;
|
buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
|
||||||
f.write_all(bytes)?;
|
buf.extend_from_slice(bytes);
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_len_prefixed_str(f: &mut File) -> Result<String, MemoryError> {
|
fn read_len_prefixed_str(f: &mut File) -> Result<String, MemoryError> {
|
||||||
|
|||||||
@@ -80,8 +80,7 @@ fn hnsw_matches_bruteforce_oracle() {
|
|||||||
oracle.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
oracle.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||||
let oracle_ids: std::collections::HashSet<usize> =
|
let oracle_ids: std::collections::HashSet<usize> =
|
||||||
oracle.iter().take(k).map(|(i, _)| *i).collect();
|
oracle.iter().take(k).map(|(i, _)| *i).collect();
|
||||||
let hnsw_ids: std::collections::HashSet<usize> =
|
let hnsw_ids: std::collections::HashSet<usize> = results.iter().map(|r| r.index).collect();
|
||||||
results.iter().map(|r| r.index).collect();
|
|
||||||
|
|
||||||
let overlap = oracle_ids.intersection(&hnsw_ids).count();
|
let overlap = oracle_ids.intersection(&hnsw_ids).count();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -127,17 +126,19 @@ fn incremental_inserts_after_search_are_found() {
|
|||||||
// First batch, then a search to force the index to build.
|
// First batch, then a search to force the index to build.
|
||||||
for i in 0..40 {
|
for i in 0..40 {
|
||||||
let v = make_vector(&mut seed, dim);
|
let v = make_vector(&mut seed, dim);
|
||||||
mem.save(entry(&format!("a{i}"), v, &format!("a{i}"))).unwrap();
|
mem.save(entry(&format!("a{i}"), v, &format!("a{i}")))
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
let _ = mem.hybrid_search(&make_vector(&mut seed, dim), "", 1.0, 0.0, 5);
|
let _ = mem.hybrid_search(&make_vector(&mut seed, dim), "", 1.0, 0.0, 5);
|
||||||
|
|
||||||
// Now insert a distinctive vector incrementally and confirm we can find it.
|
// Now insert a distinctive vector incrementally and confirm we can find it.
|
||||||
let needle = vec![10.0f32; dim];
|
let needle = vec![10.0f32; dim];
|
||||||
let idx = mem
|
let idx = mem.save(entry("needle", needle.clone(), "needle")).unwrap();
|
||||||
.save(entry("needle", needle.clone(), "needle"))
|
|
||||||
.unwrap();
|
|
||||||
let hits = mem.hybrid_search(&needle, "", 1.0, 0.0, 1);
|
let hits = mem.hybrid_search(&needle, "", 1.0, 0.0, 1);
|
||||||
assert_eq!(hits[0].index, idx, "incrementally inserted vector must be found");
|
assert_eq!(
|
||||||
|
hits[0].index, idx,
|
||||||
|
"incrementally inserted vector must be found"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -158,6 +159,9 @@ fn save_batch_then_search_is_consistent() {
|
|||||||
// Exact-match queries should resolve to themselves after a batch insert.
|
// Exact-match queries should resolve to themselves after a batch insert.
|
||||||
for probe in [0usize, 17, 49] {
|
for probe in [0usize, 17, 49] {
|
||||||
let hits = mem.hybrid_search(&vectors[probe], "", 1.0, 0.0, 1);
|
let hits = mem.hybrid_search(&vectors[probe], "", 1.0, 0.0, 1);
|
||||||
assert_eq!(hits[0].index, probe, "batch-inserted vector {probe} not found");
|
assert_eq!(
|
||||||
|
hits[0].index, probe,
|
||||||
|
"batch-inserted vector {probe} not found"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# rustyhdf5-ann
|
# clawhdf5-ann
|
||||||
|
|
||||||
[](https://crates.io/crates/rustyhdf5-ann)
|
[](https://crates.io/crates/clawhdf5-ann)
|
||||||
[](https://docs.rs/rustyhdf5-ann)
|
[](https://docs.rs/clawhdf5-ann)
|
||||||
|
|
||||||
HNSW approximate nearest neighbor index stored as HDF5.
|
HNSW approximate nearest neighbor index stored as HDF5.
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ HNSW approximate nearest neighbor index stored as HDF5.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use rustyhdf5_ann::HnswIndex;
|
use clawhdf5_ann::HnswIndex;
|
||||||
|
|
||||||
let index = HnswIndex::from_hdf5("vectors.h5").unwrap();
|
let index = HnswIndex::from_hdf5("vectors.h5").unwrap();
|
||||||
let neighbors = index.search(&query, 10);
|
let neighbors = index.search(&query, 10);
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ use clawhdf5_format::object_header::ObjectHeader;
|
|||||||
use clawhdf5_format::signature::find_signature;
|
use clawhdf5_format::signature::find_signature;
|
||||||
use clawhdf5_format::superblock::Superblock;
|
use clawhdf5_format::superblock::Superblock;
|
||||||
use clawhdf5_io::FileWriter as IoFileWriter;
|
use clawhdf5_io::FileWriter as IoFileWriter;
|
||||||
use clawhdf5_io::HDF5ReadWrite;
|
|
||||||
|
|
||||||
/// Distance metric for the HNSW index.
|
/// Distance metric for the HNSW index.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -380,7 +379,13 @@ impl HnswIndex {
|
|||||||
|
|
||||||
// Phase 1: greedy descent from the top down to node_level + 1.
|
// Phase 1: greedy descent from the top down to node_level + 1.
|
||||||
for layer in (node_level + 1..=ep_level).rev() {
|
for layer in (node_level + 1..=ep_level).rev() {
|
||||||
ep = greedy_closest(&self.vectors, &self.graph[layer], &self.vectors[id], ep, self.metric);
|
ep = greedy_closest(
|
||||||
|
&self.vectors,
|
||||||
|
&self.graph[layer],
|
||||||
|
&self.vectors[id],
|
||||||
|
ep,
|
||||||
|
self.metric,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: search and connect from min(node_level, ep_level) down to 0.
|
// Phase 2: search and connect from min(node_level, ep_level) down to 0.
|
||||||
@@ -516,7 +521,7 @@ impl HnswIndex {
|
|||||||
pub fn save_to_hdf5(&self, writer: &mut IoFileWriter) -> Result<(), FormatError> {
|
pub fn save_to_hdf5(&self, writer: &mut IoFileWriter) -> Result<(), FormatError> {
|
||||||
let bytes = self.to_hdf5_bytes()?;
|
let bytes = self.to_hdf5_bytes()?;
|
||||||
writer
|
writer
|
||||||
.write_all_bytes(&bytes)
|
.write_bytes_owned(bytes)
|
||||||
.map_err(|e| FormatError::SerializationError(e.to_string()))?;
|
.map_err(|e| FormatError::SerializationError(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1013,11 +1018,14 @@ fn get_attr_i64(attrs: &[(String, AttrValue)], name: &str) -> Result<i64, Format
|
|||||||
/// Like [`get_attr_i64`] but returns `None` when the attribute is absent or not
|
/// Like [`get_attr_i64`] but returns `None` when the attribute is absent or not
|
||||||
/// an integer, instead of erroring. Used for optional/back-compat attributes.
|
/// an integer, instead of erroring. Used for optional/back-compat attributes.
|
||||||
fn get_attr_i64_opt(attrs: &[(String, AttrValue)], name: &str) -> Option<i64> {
|
fn get_attr_i64_opt(attrs: &[(String, AttrValue)], name: &str) -> Option<i64> {
|
||||||
attrs.iter().find(|(n, _)| n == name).and_then(|(_, v)| match v {
|
attrs
|
||||||
AttrValue::I64(val) => Some(*val),
|
.iter()
|
||||||
AttrValue::U64(val) => Some(*val as i64),
|
.find(|(n, _)| n == name)
|
||||||
_ => None,
|
.and_then(|(_, v)| match v {
|
||||||
})
|
AttrValue::I64(val) => Some(*val),
|
||||||
|
AttrValue::U64(val) => Some(*val as i64),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_attr_string(attrs: &[(String, AttrValue)], name: &str) -> Result<String, FormatError> {
|
fn get_attr_string(attrs: &[(String, AttrValue)], name: &str) -> Result<String, FormatError> {
|
||||||
|
|||||||
@@ -25,8 +25,44 @@ 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
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "h5bench_write"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "h5bench_read"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "h5bench_meta"
|
||||||
|
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"
|
||||||
|
# Optional: libhdf5 C wrapper for side-by-side comparison (requires system libhdf5).
|
||||||
|
# Enable with: cargo bench -p clawhdf5-bench --features libhdf5-compare
|
||||||
|
# Uses hdf5-metno (fork of hdf5 crate) which supports HDF5 1.14.x.
|
||||||
|
hdf5 = { version = "0.12", optional = true, package = "hdf5-metno" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
clawhdf5 = { path = "../clawhdf5", features = ["zstd", "pcodec"] }
|
||||||
|
criterion = { version = "0.5", features = ["html_reports"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# When enabled, benchmarks add matching libhdf5 variants for side-by-side comparison.
|
||||||
|
libhdf5-compare = ["hdf5"]
|
||||||
|
mpi-io = ["clawhdf5-io/mpi-io", "mpi"]
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
//! h5bench-equivalent metadata workloads for clawhdf5.
|
||||||
|
//!
|
||||||
|
//! Measures attribute creation/read throughput and group traversal latency —
|
||||||
|
//! the workloads that h5bench's `metadata` mode targets against libhdf5.
|
||||||
|
|
||||||
|
use clawhdf5::{AttrValue, File, FileBuilder};
|
||||||
|
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: metadata_attrs_write
|
||||||
|
// Create K attributes on a single dataset.
|
||||||
|
// Exercises attribute message allocation and compact → dense header transition.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_metadata_attrs_write(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("metadata_attrs_write");
|
||||||
|
|
||||||
|
for &k in &[4usize, 16, 64, 128] {
|
||||||
|
group.throughput(Throughput::Elements(k as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5", k), &k, |b, &k| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("attrs_write.h5");
|
||||||
|
b.iter(|| {
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
let ds = fb
|
||||||
|
.create_dataset("data")
|
||||||
|
.with_f64_data(&[1.0, 2.0, 3.0])
|
||||||
|
.with_shape(&[3]);
|
||||||
|
for i in 0..k {
|
||||||
|
ds.set_attr(&format!("attr_{i:04}"), AttrValue::I64(i as i64));
|
||||||
|
}
|
||||||
|
fb.write(&path).unwrap();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "libhdf5-compare")]
|
||||||
|
group.bench_with_input(BenchmarkId::new("libhdf5", k), &k, |b, &k| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("attrs_libhdf5.h5");
|
||||||
|
b.iter(|| {
|
||||||
|
let file = hdf5::File::create(&path).unwrap();
|
||||||
|
let ds = file.new_dataset::<f64>().shape([3]).create("data").unwrap();
|
||||||
|
ds.write(&[1.0f64, 2.0, 3.0]).unwrap();
|
||||||
|
for i in 0..k {
|
||||||
|
ds.new_attr::<i64>()
|
||||||
|
.create(format!("attr_{i:04}").as_str())
|
||||||
|
.unwrap()
|
||||||
|
.write_scalar(&(i as i64))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: metadata_attrs_read
|
||||||
|
// Open a pre-built file and read all K attributes back.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_metadata_attrs_read(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("metadata_attrs_read");
|
||||||
|
|
||||||
|
for &k in &[4usize, 16, 64, 128] {
|
||||||
|
// Build the reference file in memory.
|
||||||
|
let bytes = {
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
let ds = fb
|
||||||
|
.create_dataset("data")
|
||||||
|
.with_f64_data(&[1.0, 2.0, 3.0])
|
||||||
|
.with_shape(&[3]);
|
||||||
|
for i in 0..k {
|
||||||
|
ds.set_attr(&format!("attr_{i:04}"), AttrValue::I64(i as i64));
|
||||||
|
}
|
||||||
|
fb.finish().unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
group.throughput(Throughput::Elements(k as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5", k), &bytes, |b, raw| {
|
||||||
|
b.iter(|| {
|
||||||
|
let file = File::from_bytes(raw.clone()).unwrap();
|
||||||
|
let ds = file.dataset("data").unwrap();
|
||||||
|
ds.attrs().unwrap()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: metadata_groups_create
|
||||||
|
// Create K top-level groups (no datasets inside).
|
||||||
|
// Measures link-storage allocation: compact → dense B-tree transition.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_metadata_groups_create(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("metadata_groups_create");
|
||||||
|
|
||||||
|
for &k in &[4usize, 16, 32, 64] {
|
||||||
|
group.throughput(Throughput::Elements(k as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5", k), &k, |b, &k| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("groups_create.h5");
|
||||||
|
b.iter(|| {
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
for i in 0..k {
|
||||||
|
let mut g = fb.create_group(&format!("group_{i:04}"));
|
||||||
|
// Minimal dataset inside each group to make it non-trivial.
|
||||||
|
g.create_dataset("x").with_f64_data(&[0.0]);
|
||||||
|
let finished = g.finish();
|
||||||
|
fb.add_group(finished);
|
||||||
|
}
|
||||||
|
fb.write(&path).unwrap();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "libhdf5-compare")]
|
||||||
|
group.bench_with_input(BenchmarkId::new("libhdf5", k), &k, |b, &k| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("groups_libhdf5.h5");
|
||||||
|
b.iter(|| {
|
||||||
|
let file = hdf5::File::create(&path).unwrap();
|
||||||
|
for i in 0..k {
|
||||||
|
let g = file.create_group(&format!("group_{i:04}")).unwrap();
|
||||||
|
g.new_dataset::<f64>()
|
||||||
|
.shape([1])
|
||||||
|
.create("x")
|
||||||
|
.unwrap()
|
||||||
|
.write(&[0.0f64])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: metadata_groups_traverse
|
||||||
|
// Open a pre-built file with K groups and traverse (list) the root group.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_metadata_groups_traverse(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("metadata_groups_traverse");
|
||||||
|
|
||||||
|
for &k in &[4usize, 16, 32, 64] {
|
||||||
|
// Pre-build.
|
||||||
|
let bytes = {
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
for i in 0..k {
|
||||||
|
let mut g = fb.create_group(&format!("group_{i:04}"));
|
||||||
|
g.create_dataset("x").with_f64_data(&[0.0]);
|
||||||
|
let finished = g.finish();
|
||||||
|
fb.add_group(finished);
|
||||||
|
}
|
||||||
|
fb.finish().unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
group.throughput(Throughput::Elements(k as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5", k), &bytes, |b, raw| {
|
||||||
|
b.iter(|| {
|
||||||
|
let file = File::from_bytes(raw.clone()).unwrap();
|
||||||
|
let root = file.root();
|
||||||
|
root.groups().unwrap()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: metadata_roundtrip_string_attrs
|
||||||
|
// Write and read back K variable-length string attributes.
|
||||||
|
// String attrs require a dedicated VL heap entry — distinct from numeric ones.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_metadata_string_attrs(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("metadata_string_attrs");
|
||||||
|
|
||||||
|
for &k in &[4usize, 16, 32] {
|
||||||
|
group.throughput(Throughput::Elements(k as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5", k), &k, |b, &k| {
|
||||||
|
b.iter(|| {
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
let ds = fb
|
||||||
|
.create_dataset("data")
|
||||||
|
.with_f64_data(&[1.0])
|
||||||
|
.with_shape(&[1]);
|
||||||
|
for i in 0..k {
|
||||||
|
ds.set_attr(
|
||||||
|
&format!("label_{i:04}"),
|
||||||
|
AttrValue::String(format!("value-{i}-some-longer-string-payload")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let bytes = fb.finish().unwrap();
|
||||||
|
|
||||||
|
// Immediately read back to exercise both directions.
|
||||||
|
let file = File::from_bytes(bytes).unwrap();
|
||||||
|
let ds_r = file.dataset("data").unwrap();
|
||||||
|
ds_r.attrs().unwrap()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: metadata_open_from_disk
|
||||||
|
// Open a small pre-built file from disk and resolve one attribute. Both
|
||||||
|
// sides pay the OS open()/read() cost plus header-parse cost, so this is a
|
||||||
|
// fair, I/O-inclusive "open a file and touch its metadata" comparison — the
|
||||||
|
// honest version of the "metadata parse" claim this benchmark replaces.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_metadata_open_from_disk(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("metadata_open_from_disk");
|
||||||
|
group.throughput(Throughput::Elements(1));
|
||||||
|
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let clawhdf5_path = tmp.path().join("open_clawhdf5.h5");
|
||||||
|
{
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
let ds = fb
|
||||||
|
.create_dataset("data")
|
||||||
|
.with_f64_data(&[1.0, 2.0, 3.0])
|
||||||
|
.with_shape(&[3]);
|
||||||
|
ds.set_attr("label", AttrValue::I64(42));
|
||||||
|
fb.write(&clawhdf5_path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
group.bench_function("clawhdf5", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let raw = std::fs::read(&clawhdf5_path).unwrap();
|
||||||
|
let file = File::from_bytes(raw).unwrap();
|
||||||
|
let ds = file.dataset("data").unwrap();
|
||||||
|
ds.attrs().unwrap()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "libhdf5-compare")]
|
||||||
|
{
|
||||||
|
let libhdf5_path = tmp.path().join("open_libhdf5.h5");
|
||||||
|
{
|
||||||
|
let file = hdf5::File::create(&libhdf5_path).unwrap();
|
||||||
|
let ds = file.new_dataset::<f64>().shape([3]).create("data").unwrap();
|
||||||
|
ds.write(&[1.0f64, 2.0, 3.0]).unwrap();
|
||||||
|
ds.new_attr::<i64>()
|
||||||
|
.create("label")
|
||||||
|
.unwrap()
|
||||||
|
.write_scalar(&42i64)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
group.bench_function("libhdf5", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let file = hdf5::File::open(&libhdf5_path).unwrap();
|
||||||
|
let ds = file.dataset("data").unwrap();
|
||||||
|
let _: i64 = ds.attr("label").unwrap().read_scalar().unwrap();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: metadata_parse_in_memory (clawhdf5-only)
|
||||||
|
// Times File::from_bytes() alone on bytes already resident in memory — i.e.
|
||||||
|
// the header-parse cost with disk I/O excluded. There is no fair libhdf5
|
||||||
|
// equivalent (its API has no "parse from an in-memory buffer" path that
|
||||||
|
// skips the OS open), so this is reported standalone, not as a speedup
|
||||||
|
// multiple against libhdf5. See metadata_open_from_disk above for the
|
||||||
|
// I/O-inclusive, directly comparable number.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_metadata_parse_in_memory(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("metadata_parse_in_memory");
|
||||||
|
group.throughput(Throughput::Elements(1));
|
||||||
|
|
||||||
|
let bytes = {
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
let ds = fb
|
||||||
|
.create_dataset("data")
|
||||||
|
.with_f64_data(&[1.0, 2.0, 3.0])
|
||||||
|
.with_shape(&[3]);
|
||||||
|
ds.set_attr("label", AttrValue::I64(42));
|
||||||
|
fb.finish().unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
group.bench_with_input(
|
||||||
|
BenchmarkId::new("clawhdf5", "in_memory"),
|
||||||
|
&bytes,
|
||||||
|
|b, raw| {
|
||||||
|
b.iter(|| {
|
||||||
|
let file = File::from_bytes(raw.clone()).unwrap();
|
||||||
|
let ds = file.dataset("data").unwrap();
|
||||||
|
ds.attrs().unwrap()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(
|
||||||
|
meta_benches,
|
||||||
|
bench_metadata_attrs_write,
|
||||||
|
bench_metadata_attrs_read,
|
||||||
|
bench_metadata_groups_create,
|
||||||
|
bench_metadata_groups_traverse,
|
||||||
|
bench_metadata_string_attrs,
|
||||||
|
bench_metadata_open_from_disk,
|
||||||
|
bench_metadata_parse_in_memory,
|
||||||
|
);
|
||||||
|
criterion_main!(meta_benches);
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
//! h5bench-equivalent read workloads for clawhdf5.
|
||||||
|
//!
|
||||||
|
//! Covers sequential read, hyperslab / strided access, and round-trip
|
||||||
|
//! validation patterns mirroring the h5bench HPC read suite.
|
||||||
|
|
||||||
|
use clawhdf5::{File, FileBuilder};
|
||||||
|
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers: build reference files once per bench group.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Write a contiguous 1-D f32 dataset and return raw bytes.
|
||||||
|
fn make_1d_contiguous_bytes(n: usize) -> Vec<u8> {
|
||||||
|
let data: Vec<f32> = (0..n).map(|i| i as f32 * 0.001).collect();
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
fb.create_dataset("data")
|
||||||
|
.with_f32_data(&data)
|
||||||
|
.with_shape(&[n as u64]);
|
||||||
|
fb.finish().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a contiguous 1-D f64 dataset and return raw bytes.
|
||||||
|
fn make_1d_f64_bytes(n: usize) -> Vec<u8> {
|
||||||
|
let data: Vec<f64> = (0..n).map(|i| i as f64 * 0.001).collect();
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
fb.create_dataset("data")
|
||||||
|
.with_f64_data(&data)
|
||||||
|
.with_shape(&[n as u64]);
|
||||||
|
fb.finish().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a 2-D chunked f32 matrix to a temp file, return path string.
|
||||||
|
///
|
||||||
|
/// The temp dir is returned to keep the directory alive.
|
||||||
|
fn make_2d_chunked_file(tmp: &TempDir, rows: usize, cols: usize) -> std::path::PathBuf {
|
||||||
|
let data: Vec<f32> = (0..rows * cols).map(|i| i as f32).collect();
|
||||||
|
let path = tmp.path().join("chunked.h5");
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
fb.create_dataset("matrix")
|
||||||
|
.with_f32_data(&data)
|
||||||
|
.with_shape(&[rows as u64, cols as u64])
|
||||||
|
.with_chunks(&[32, cols as u64]);
|
||||||
|
fb.write(&path).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: read_sequential
|
||||||
|
// Read back the full 1-D contiguous f32 dataset.
|
||||||
|
// Measures parser + byte-copy throughput.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_read_sequential(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("read_sequential");
|
||||||
|
|
||||||
|
for &n in &[1_000usize, 10_000, 100_000] {
|
||||||
|
let bytes = make_1d_contiguous_bytes(n);
|
||||||
|
group.throughput(Throughput::Bytes((n * size_of::<f32>()) as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5", n), &bytes, |b, raw| {
|
||||||
|
b.iter(|| {
|
||||||
|
let file = File::from_bytes(raw.clone()).unwrap();
|
||||||
|
let ds = file.dataset("data").unwrap();
|
||||||
|
ds.read_f32().unwrap()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "libhdf5-compare")]
|
||||||
|
group.bench_with_input(BenchmarkId::new("libhdf5", n), &n, |b, &nn| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("seq_libhdf5.h5");
|
||||||
|
let data: Vec<f32> = (0..nn).map(|i| i as f32 * 0.001).collect();
|
||||||
|
{
|
||||||
|
let lf = hdf5::File::create(&path).unwrap();
|
||||||
|
let lds = lf.new_dataset::<f32>().shape([nn]).create("data").unwrap();
|
||||||
|
lds.write(data.as_slice()).unwrap();
|
||||||
|
}
|
||||||
|
b.iter(|| {
|
||||||
|
let file = hdf5::File::open(&path).unwrap();
|
||||||
|
let ds = file.dataset("data").unwrap();
|
||||||
|
ds.read_raw::<f32>().unwrap()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: read_f64_sequential
|
||||||
|
// Same as above but for f64 — the dominant agent-embedding dtype.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_read_f64_sequential(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("read_f64_sequential");
|
||||||
|
|
||||||
|
for &n in &[1_000usize, 10_000, 100_000] {
|
||||||
|
let bytes = make_1d_f64_bytes(n);
|
||||||
|
group.throughput(Throughput::Bytes((n * size_of::<f64>()) as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5", n), &bytes, |b, raw| {
|
||||||
|
b.iter(|| {
|
||||||
|
let file = File::from_bytes(raw.clone()).unwrap();
|
||||||
|
let ds = file.dataset("data").unwrap();
|
||||||
|
ds.read_f64().unwrap()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: read_chunked_2d
|
||||||
|
// Read back a 2-D chunked f32 matrix from disk (exercises chunk reassembly).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_read_chunked_2d(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("read_chunked_2d");
|
||||||
|
|
||||||
|
for &(rows, cols) in &[(64usize, 64usize), (256, 256), (512, 512)] {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = make_2d_chunked_file(&tmp, rows, cols);
|
||||||
|
let n = rows * cols;
|
||||||
|
group.throughput(Throughput::Bytes((n * size_of::<f32>()) as u64));
|
||||||
|
let label = format!("{rows}x{cols}");
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5", &label), &path, |b, p| {
|
||||||
|
b.iter(|| {
|
||||||
|
let raw = std::fs::read(p).unwrap();
|
||||||
|
let file = File::from_bytes(raw).unwrap();
|
||||||
|
let ds = file.dataset("matrix").unwrap();
|
||||||
|
ds.read_f32().unwrap()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: read_from_disk
|
||||||
|
// Open file from disk (FileBuilder::write → File::open) measuring OS I/O +
|
||||||
|
// HDF5 parse together. Simulates cold-cache reads.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_read_from_disk(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("read_from_disk");
|
||||||
|
|
||||||
|
for &n in &[10_000usize, 100_000] {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("disk.h5");
|
||||||
|
|
||||||
|
let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
fb.create_dataset("data")
|
||||||
|
.with_f64_data(&data)
|
||||||
|
.with_shape(&[n as u64]);
|
||||||
|
fb.write(&path).unwrap();
|
||||||
|
|
||||||
|
group.throughput(Throughput::Bytes((n * size_of::<f64>()) as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5", n), &path, |b, p| {
|
||||||
|
b.iter(|| {
|
||||||
|
let raw = std::fs::read(p).unwrap();
|
||||||
|
let file = File::from_bytes(raw).unwrap();
|
||||||
|
file.dataset("data").unwrap().read_f64().unwrap()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: read_hyperslab
|
||||||
|
// Reads a subset of a 1-D dataset (simulating strided / hyperslab access).
|
||||||
|
// Uses every-other element to stress the selection logic.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_read_hyperslab(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("read_hyperslab");
|
||||||
|
|
||||||
|
for &n in &[10_000usize, 100_000] {
|
||||||
|
let bytes = make_1d_f64_bytes(n);
|
||||||
|
// Read first 10% of the dataset as a proxy for hyperslab access.
|
||||||
|
let slice_len = n / 10;
|
||||||
|
group.throughput(Throughput::Bytes((slice_len * size_of::<f64>()) as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5", n), &bytes, |b, raw| {
|
||||||
|
b.iter(|| {
|
||||||
|
let file = File::from_bytes(raw.clone()).unwrap();
|
||||||
|
let ds = file.dataset("data").unwrap();
|
||||||
|
// Full read then take a slice — clawhdf5 does not yet expose
|
||||||
|
// selection API at the high-level facade, so we read all and
|
||||||
|
// trim (this is what the format-level selection exercises).
|
||||||
|
let all = ds.read_f64().unwrap();
|
||||||
|
all[..slice_len].to_vec()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: read_zerocopy_mmap
|
||||||
|
// Opens a file from disk via `MmapFile` and reads an f64 dataset through
|
||||||
|
// `read_f64_zerocopy()`, which returns a slice directly into the mapped
|
||||||
|
// pages (no allocation, no copy). Compared against the regular
|
||||||
|
// std::fs::read + File::from_bytes path (which does copy), and — with
|
||||||
|
// libhdf5-compare — against libhdf5's own disk-backed open+read.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_read_zerocopy_mmap(c: &mut Criterion) {
|
||||||
|
use clawhdf5::MmapFile;
|
||||||
|
|
||||||
|
let mut group = c.benchmark_group("read_zerocopy_mmap");
|
||||||
|
|
||||||
|
for &n in &[1_000usize, 10_000, 100_000] {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("mmap.h5");
|
||||||
|
let data: Vec<f64> = (0..n).map(|i| i as f64 * 0.001).collect();
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
fb.create_dataset("data")
|
||||||
|
.with_f64_data(&data)
|
||||||
|
.with_shape(&[n as u64]);
|
||||||
|
fb.write(&path).unwrap();
|
||||||
|
|
||||||
|
group.throughput(Throughput::Bytes((n * size_of::<f64>()) as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(
|
||||||
|
BenchmarkId::new("clawhdf5_mmap_zerocopy", n),
|
||||||
|
&path,
|
||||||
|
|b, p| {
|
||||||
|
b.iter(|| {
|
||||||
|
let file = MmapFile::open(p).unwrap();
|
||||||
|
let ds = file.dataset("data").unwrap();
|
||||||
|
let slice = ds.read_f64_zerocopy().unwrap();
|
||||||
|
// Sum every element to force the mapped pages to actually be
|
||||||
|
// faulted in — returning just `.len()` would measure nothing
|
||||||
|
// but the mmap() syscall, repeating the exact "too-fast-to-
|
||||||
|
// be-real" mistake this benchmark exists to fix.
|
||||||
|
let sum: f64 = slice.map(|s| s.iter().sum()).unwrap_or(0.0);
|
||||||
|
criterion::black_box(sum)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5_copy", n), &path, |b, p| {
|
||||||
|
b.iter(|| {
|
||||||
|
let raw = std::fs::read(p).unwrap();
|
||||||
|
let file = File::from_bytes(raw).unwrap();
|
||||||
|
file.dataset("data").unwrap().read_f64().unwrap()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "libhdf5-compare")]
|
||||||
|
group.bench_with_input(BenchmarkId::new("libhdf5", n), &n, |b, &nn| {
|
||||||
|
let tmp2 = TempDir::new().unwrap();
|
||||||
|
let path2 = tmp2.path().join("mmap_libhdf5.h5");
|
||||||
|
let data2: Vec<f64> = (0..nn).map(|i| i as f64 * 0.001).collect();
|
||||||
|
{
|
||||||
|
let lf = hdf5::File::create(&path2).unwrap();
|
||||||
|
let lds = lf.new_dataset::<f64>().shape([nn]).create("data").unwrap();
|
||||||
|
lds.write(data2.as_slice()).unwrap();
|
||||||
|
}
|
||||||
|
b.iter(|| {
|
||||||
|
let file = hdf5::File::open(&path2).unwrap();
|
||||||
|
let ds = file.dataset("data").unwrap();
|
||||||
|
ds.read_raw::<f64>().unwrap()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(
|
||||||
|
read_benches,
|
||||||
|
bench_read_sequential,
|
||||||
|
bench_read_f64_sequential,
|
||||||
|
bench_read_chunked_2d,
|
||||||
|
bench_read_from_disk,
|
||||||
|
bench_read_hyperslab,
|
||||||
|
bench_read_zerocopy_mmap,
|
||||||
|
);
|
||||||
|
criterion_main!(read_benches);
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
//! h5bench-equivalent write workloads for clawhdf5.
|
||||||
|
//!
|
||||||
|
//! Mirrors the sequential and chunked write patterns from the h5bench HPC
|
||||||
|
//! benchmark suite but implemented in pure Rust using Criterion for statistical
|
||||||
|
//! rigor. The `libhdf5-compare` feature adds matching benchmarks via the `hdf5`
|
||||||
|
//! crate (requires a system libhdf5 install).
|
||||||
|
|
||||||
|
use clawhdf5::{AttrValue, FileBuilder};
|
||||||
|
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: write_1d_contiguous
|
||||||
|
// Write N × f32 as a single contiguous 1-D dataset.
|
||||||
|
// Measures raw serialization + HDF5 superblock / object-header overhead.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_write_1d_contiguous(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("write_1d_contiguous");
|
||||||
|
|
||||||
|
for &n in &[1_000usize, 10_000, 100_000] {
|
||||||
|
let data: Vec<f32> = (0..n).map(|i| i as f32 * 0.001).collect();
|
||||||
|
group.throughput(Throughput::Bytes((n * size_of::<f32>()) as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5", n), &data, |b, d| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("write_1d_contiguous.h5");
|
||||||
|
b.iter(|| {
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
fb.create_dataset("data")
|
||||||
|
.with_f32_data(d)
|
||||||
|
.with_shape(&[n as u64]);
|
||||||
|
fb.write(&path).unwrap();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "libhdf5-compare")]
|
||||||
|
group.bench_with_input(BenchmarkId::new("libhdf5", n), &data, |b, d| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("write_1d_libhdf5.h5");
|
||||||
|
b.iter(|| {
|
||||||
|
let file = hdf5::File::create(&path).unwrap();
|
||||||
|
let ds = file
|
||||||
|
.new_dataset::<f32>()
|
||||||
|
.shape([d.len()])
|
||||||
|
.create("data")
|
||||||
|
.unwrap();
|
||||||
|
ds.write(d.as_slice()).unwrap();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: write_2d_chunked
|
||||||
|
// Write an M × N f32 matrix as a chunked 2-D dataset with deflate (level 6).
|
||||||
|
// Measures chunked layout creation + compression pipeline throughput.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_write_2d_chunked(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("write_2d_chunked");
|
||||||
|
|
||||||
|
// (rows, cols, chunk_rows, chunk_cols)
|
||||||
|
let configs: &[(usize, usize, u64, u64)] =
|
||||||
|
&[(32, 32, 8, 32), (128, 128, 32, 128), (512, 512, 64, 512)];
|
||||||
|
|
||||||
|
for &(rows, cols, cr, cc) in configs {
|
||||||
|
let n = rows * cols;
|
||||||
|
let data: Vec<f32> = (0..n).map(|i| i as f32).collect();
|
||||||
|
let label = format!("{rows}x{cols}");
|
||||||
|
group.throughput(Throughput::Bytes((n * size_of::<f32>()) as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5", &label), &data, |b, d| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("write_2d_chunked.h5");
|
||||||
|
b.iter(|| {
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
fb.create_dataset("matrix")
|
||||||
|
.with_f32_data(d)
|
||||||
|
.with_shape(&[rows as u64, cols as u64])
|
||||||
|
.with_chunks(&[cr, cc])
|
||||||
|
.with_deflate(6);
|
||||||
|
fb.write(&path).unwrap();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "libhdf5-compare")]
|
||||||
|
group.bench_with_input(BenchmarkId::new("libhdf5", &label), &data, |b, d| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("write_2d_libhdf5.h5");
|
||||||
|
b.iter(|| {
|
||||||
|
let file = hdf5::File::create(&path).unwrap();
|
||||||
|
let ds = file
|
||||||
|
.new_dataset::<f32>()
|
||||||
|
.shape([rows, cols])
|
||||||
|
.chunk([cr as usize, cc as usize])
|
||||||
|
.deflate(6)
|
||||||
|
.create("matrix")
|
||||||
|
.unwrap();
|
||||||
|
ds.write_raw(d.as_slice()).unwrap();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: write_2d_chunked_zstd
|
||||||
|
// Same matrix sizes as write_2d_chunked but uses Zstd level 3.
|
||||||
|
// Zstd level 3 typically encodes 500+ MiB/s vs deflate's ~300 MiB/s at the
|
||||||
|
// same or better compression ratio (arXiv 2604.06221, ROOT I/O 2019).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_write_2d_chunked_zstd(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("write_2d_chunked_zstd");
|
||||||
|
|
||||||
|
let configs: &[(usize, usize, u64, u64)] =
|
||||||
|
&[(32, 32, 8, 32), (128, 128, 32, 128), (512, 512, 64, 512)];
|
||||||
|
|
||||||
|
for &(rows, cols, cr, cc) in configs {
|
||||||
|
let n = rows * cols;
|
||||||
|
let data: Vec<f32> = (0..n).map(|i| i as f32).collect();
|
||||||
|
let label = format!("{rows}x{cols}");
|
||||||
|
group.throughput(Throughput::Bytes((n * size_of::<f32>()) as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(
|
||||||
|
BenchmarkId::new("clawhdf5/zstd-3", &label),
|
||||||
|
&data,
|
||||||
|
|b, d| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("write_2d_chunked_zstd.h5");
|
||||||
|
b.iter(|| {
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
fb.create_dataset("matrix")
|
||||||
|
.with_f32_data(d)
|
||||||
|
.with_shape(&[rows as u64, cols as u64])
|
||||||
|
.with_chunks(&[cr, cc])
|
||||||
|
.with_zstd(3);
|
||||||
|
fb.write(&path).unwrap();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
group.bench_with_input(
|
||||||
|
BenchmarkId::new("clawhdf5/deflate-6", &label),
|
||||||
|
&data,
|
||||||
|
|b, d| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("write_2d_chunked_deflate.h5");
|
||||||
|
b.iter(|| {
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
fb.create_dataset("matrix")
|
||||||
|
.with_f32_data(d)
|
||||||
|
.with_shape(&[rows as u64, cols as u64])
|
||||||
|
.with_chunks(&[cr, cc])
|
||||||
|
.with_deflate(6);
|
||||||
|
fb.write(&path).unwrap();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: write_2d_chunked_pcodec
|
||||||
|
// Same matrix sizes as write_2d_chunked but uses Pcodec (arXiv:2502.06112).
|
||||||
|
// Pcodec achieves 30–94% better compression ratio than Zstd for f32/f64 at
|
||||||
|
// 1–5 GiB/s decompression speed via a quantile-based numerical codec.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_write_2d_chunked_pcodec(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("write_2d_chunked_pcodec");
|
||||||
|
|
||||||
|
let configs: &[(usize, usize, u64, u64)] =
|
||||||
|
&[(32, 32, 8, 32), (128, 128, 32, 128), (512, 512, 64, 512)];
|
||||||
|
|
||||||
|
for &(rows, cols, cr, cc) in configs {
|
||||||
|
let n = rows * cols;
|
||||||
|
let data: Vec<f32> = (0..n).map(|i| i as f32).collect();
|
||||||
|
let label = format!("{rows}x{cols}");
|
||||||
|
group.throughput(Throughput::Bytes((n * size_of::<f32>()) as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(
|
||||||
|
BenchmarkId::new("clawhdf5/pcodec", &label),
|
||||||
|
&data,
|
||||||
|
|b, d| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("write_2d_chunked_pcodec.h5");
|
||||||
|
b.iter(|| {
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
fb.create_dataset("matrix")
|
||||||
|
.with_f32_data(d)
|
||||||
|
.with_shape(&[rows as u64, cols as u64])
|
||||||
|
.with_chunks(&[cr, cc])
|
||||||
|
.with_pcodec();
|
||||||
|
fb.write(&path).unwrap();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
group.bench_with_input(
|
||||||
|
BenchmarkId::new("clawhdf5/zstd-3", &label),
|
||||||
|
&data,
|
||||||
|
|b, d| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("write_2d_chunked_zstd.h5");
|
||||||
|
b.iter(|| {
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
fb.create_dataset("matrix")
|
||||||
|
.with_f32_data(d)
|
||||||
|
.with_shape(&[rows as u64, cols as u64])
|
||||||
|
.with_chunks(&[cr, cc])
|
||||||
|
.with_zstd(3);
|
||||||
|
fb.write(&path).unwrap();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: write_f64_batch
|
||||||
|
// Write batches of f64 elements — simulates the clawhdf5-agent embedding
|
||||||
|
// write path (one f64 vector per memory entry).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_write_f64_batch(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("write_f64_batch");
|
||||||
|
|
||||||
|
for &n in &[128usize, 512, 1_024] {
|
||||||
|
let data: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
|
||||||
|
group.throughput(Throughput::Bytes((n * size_of::<f64>()) as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5", n), &data, |b, d| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("write_f64_batch.h5");
|
||||||
|
b.iter(|| {
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
fb.create_dataset("embedding")
|
||||||
|
.with_f64_data(d)
|
||||||
|
.with_shape(&[n as u64]);
|
||||||
|
fb.write(&path).unwrap();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: write_multi_dataset
|
||||||
|
// Write K independent f32 datasets into one file — stresses the object-header
|
||||||
|
// + link-storage path (compact → dense transition at >8 datasets).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_write_multi_dataset(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("write_multi_dataset");
|
||||||
|
|
||||||
|
for &k in &[4usize, 16, 64] {
|
||||||
|
let rows = 100usize;
|
||||||
|
let data: Vec<f32> = (0..rows).map(|i| i as f32).collect();
|
||||||
|
group.throughput(Throughput::Elements(k as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5", k), &data, |b, d| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("write_multi.h5");
|
||||||
|
b.iter(|| {
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
for i in 0..k {
|
||||||
|
fb.create_dataset(&format!("ds_{i:04}"))
|
||||||
|
.with_f32_data(d)
|
||||||
|
.with_shape(&[rows as u64]);
|
||||||
|
}
|
||||||
|
fb.write(&path).unwrap();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workload: write_with_attrs
|
||||||
|
// Write a dataset with K attributes — exercises attribute message allocation.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bench_write_with_attrs(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("write_with_attrs");
|
||||||
|
|
||||||
|
for &k in &[4usize, 16, 64] {
|
||||||
|
group.throughput(Throughput::Elements(k as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("clawhdf5", k), &k, |b, &k| {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("write_attrs.h5");
|
||||||
|
b.iter(|| {
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
let ds = fb
|
||||||
|
.create_dataset("data")
|
||||||
|
.with_f64_data(&[1.0, 2.0, 3.0])
|
||||||
|
.with_shape(&[3]);
|
||||||
|
for i in 0..k {
|
||||||
|
ds.set_attr(&format!("attr_{i}"), AttrValue::I64(i as i64));
|
||||||
|
}
|
||||||
|
fb.write(&path).unwrap();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(
|
||||||
|
write_benches,
|
||||||
|
bench_write_1d_contiguous,
|
||||||
|
bench_write_2d_chunked,
|
||||||
|
bench_write_2d_chunked_zstd,
|
||||||
|
bench_write_2d_chunked_pcodec,
|
||||||
|
bench_write_f64_batch,
|
||||||
|
bench_write_multi_dataset,
|
||||||
|
bench_write_with_attrs,
|
||||||
|
);
|
||||||
|
criterion_main!(write_benches);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# rustyhdf5-derive
|
# clawhdf5-derive
|
||||||
|
|
||||||
[](https://crates.io/crates/rustyhdf5-derive)
|
[](https://crates.io/crates/clawhdf5-derive)
|
||||||
[](https://docs.rs/rustyhdf5-derive)
|
[](https://docs.rs/clawhdf5-derive)
|
||||||
|
|
||||||
Derive macros for rustyhdf5 HDF5 traits.
|
Derive macros for clawhdf5 HDF5 traits.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ Derive macros for rustyhdf5 HDF5 traits.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use rustyhdf5_derive::HDF5Type;
|
use clawhdf5_derive::HDF5Type;
|
||||||
|
|
||||||
#[derive(HDF5Type)]
|
#[derive(HDF5Type)]
|
||||||
struct Point {
|
struct Point {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# rustyhdf5-filters
|
# clawhdf5-filters
|
||||||
|
|
||||||
[](https://crates.io/crates/rustyhdf5-filters)
|
[](https://crates.io/crates/clawhdf5-filters)
|
||||||
[](https://docs.rs/rustyhdf5-filters)
|
[](https://docs.rs/clawhdf5-filters)
|
||||||
|
|
||||||
Filter and compression pipeline for rustyhdf5.
|
Filter and compression pipeline for clawhdf5.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ Filter and compression pipeline for rustyhdf5.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use rustyhdf5_filters::{deflate_decode, deflate_encode};
|
use clawhdf5_filters::{deflate_decode, deflate_encode};
|
||||||
|
|
||||||
let compressed = deflate_encode(&data, 6).unwrap();
|
let compressed = deflate_encode(&data, 6).unwrap();
|
||||||
let decompressed = deflate_decode(&compressed).unwrap();
|
let decompressed = deflate_decode(&compressed).unwrap();
|
||||||
|
|||||||
@@ -270,14 +270,29 @@ pub(crate) fn flate2_decompress_preallocated(
|
|||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Absolute ceiling on decompressed output when the caller has no size hint,
|
||||||
|
/// preventing unbounded allocation from a hostile/corrupted zlib stream.
|
||||||
|
const MAX_DECOMPRESS_SIZE: usize = 256 * 1024 * 1024;
|
||||||
|
|
||||||
/// Streaming decompress with dynamic sizing (when output size is unknown).
|
/// Streaming decompress with dynamic sizing (when output size is unknown).
|
||||||
|
///
|
||||||
|
/// Bounded by [`MAX_DECOMPRESS_SIZE`] since there is no chunk-size hint to
|
||||||
|
/// validate against here — an unbounded `read_to_end` would let a hostile
|
||||||
|
/// zlib stream force arbitrarily large allocation (a "zlib bomb").
|
||||||
pub(crate) fn flate2_decompress_streaming(data: &[u8]) -> Result<Vec<u8>, String> {
|
pub(crate) fn flate2_decompress_streaming(data: &[u8]) -> Result<Vec<u8>, String> {
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
let mut decoder = flate2::read::ZlibDecoder::new(data);
|
let decoder = flate2::read::ZlibDecoder::new(data);
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
decoder
|
decoder
|
||||||
|
.take(MAX_DECOMPRESS_SIZE as u64 + 1)
|
||||||
.read_to_end(&mut result)
|
.read_to_end(&mut result)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
if result.len() > MAX_DECOMPRESS_SIZE {
|
||||||
|
return Err(format!(
|
||||||
|
"decompressed output exceeds {} MiB limit",
|
||||||
|
MAX_DECOMPRESS_SIZE / 1024 / 1024
|
||||||
|
));
|
||||||
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ categories = ["parser-implementations", "science", "encoding", "no-std"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
byteorder = { version = "1", default-features = false }
|
byteorder = { version = "1", default-features = false }
|
||||||
|
portable-atomic = { version = "1" }
|
||||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"], optional = true }
|
flate2 = { version = "1", default-features = false, features = ["rust_backend"], optional = true }
|
||||||
sha2 = { version = "0.10", default-features = false, optional = true }
|
sha2 = { version = "0.10", default-features = false, optional = true }
|
||||||
rayon = { version = "1", optional = true }
|
rayon = { version = "1", optional = true }
|
||||||
@@ -18,6 +19,8 @@ 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 }
|
||||||
|
pco = { version = "1.0", optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -43,6 +46,8 @@ 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"]
|
||||||
|
pcodec = ["dep:pco"]
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "parallel_decompress_bench"
|
name = "parallel_decompress_bench"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# rustyhdf5-format
|
# clawhdf5-format
|
||||||
|
|
||||||
[](https://crates.io/crates/rustyhdf5-format)
|
[](https://crates.io/crates/clawhdf5-format)
|
||||||
[](https://docs.rs/rustyhdf5-format)
|
[](https://docs.rs/clawhdf5-format)
|
||||||
|
|
||||||
Pure-Rust HDF5 binary format parsing and writing — no C dependencies.
|
Pure-Rust HDF5 binary format parsing and writing — no C dependencies.
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Pure-Rust HDF5 binary format parsing and writing — no C dependencies.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use rustyhdf5_format::Superblock;
|
use clawhdf5_format::Superblock;
|
||||||
|
|
||||||
let data = std::fs::read("data.h5").unwrap();
|
let data = std::fs::read("data.h5").unwrap();
|
||||||
let sb = Superblock::from_bytes(&data).unwrap();
|
let sb = Superblock::from_bytes(&data).unwrap();
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ use core::ops::{Deref, DerefMut};
|
|||||||
use alloc::collections::BTreeMap;
|
use alloc::collections::BTreeMap;
|
||||||
#[cfg(feature = "std")]
|
#[cfg(feature = "std")]
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::chunk_index::{ChunkIndex, ChunkLayout};
|
use crate::chunk_index::{ChunkIndex, ChunkLayout};
|
||||||
use crate::chunked_read::ChunkInfo;
|
use crate::chunked_read::ChunkInfo;
|
||||||
@@ -64,6 +66,11 @@ pub struct CacheAlignedBuffer {
|
|||||||
|
|
||||||
// SAFETY: The raw pointer is exclusively owned — no aliasing.
|
// SAFETY: The raw pointer is exclusively owned — no aliasing.
|
||||||
unsafe impl Send for CacheAlignedBuffer {}
|
unsafe impl Send for CacheAlignedBuffer {}
|
||||||
|
// SAFETY: `CacheAlignedBuffer` exposes its contents only via `&[u8]`/`&mut
|
||||||
|
// [u8]` through the ordinary borrow-checked `Deref`/`DerefMut` impls below —
|
||||||
|
// the same access pattern as `Vec<u8>`, which is `Sync`. Needed so
|
||||||
|
// `Arc<CacheAlignedBuffer>` (used by the chunk cache) is itself `Send`.
|
||||||
|
unsafe impl Sync for CacheAlignedBuffer {}
|
||||||
|
|
||||||
impl CacheAlignedBuffer {
|
impl CacheAlignedBuffer {
|
||||||
/// Allocate a new cache-line-aligned buffer of exactly `len` bytes,
|
/// Allocate a new cache-line-aligned buffer of exactly `len` bytes,
|
||||||
@@ -223,7 +230,9 @@ pub const DEFAULT_MAX_SLOTS: usize = 521;
|
|||||||
#[cfg(feature = "std")]
|
#[cfg(feature = "std")]
|
||||||
struct CachedChunk {
|
struct CachedChunk {
|
||||||
coord: ChunkCoord,
|
coord: ChunkCoord,
|
||||||
data: CacheAlignedBuffer,
|
/// Shared so a cache hit is a refcount bump, not a copy of the whole
|
||||||
|
/// (potentially large) decompressed chunk.
|
||||||
|
data: Arc<CacheAlignedBuffer>,
|
||||||
/// Monotonically increasing access counter for LRU ordering.
|
/// Monotonically increasing access counter for LRU ordering.
|
||||||
last_access: u64,
|
last_access: u64,
|
||||||
}
|
}
|
||||||
@@ -256,9 +265,23 @@ struct CacheInner {
|
|||||||
/// Populated once per dataset on first access.
|
/// Populated once per dataset on first access.
|
||||||
index: Option<HashMap<ChunkCoord, ChunkInfo>>,
|
index: Option<HashMap<ChunkCoord, ChunkInfo>>,
|
||||||
|
|
||||||
|
/// Address of the dataset (its chunk-index base address) that the cached
|
||||||
|
/// index, chunk index, layout, and decompressed slots currently belong to.
|
||||||
|
/// The cache is shared per file across datasets, so every cached-read entry
|
||||||
|
/// checks this and resets the per-dataset state when the dataset changes —
|
||||||
|
/// otherwise one dataset's chunk index (with its own rank) would be reused
|
||||||
|
/// for another, corrupting reads.
|
||||||
|
index_addr: Option<u64>,
|
||||||
|
|
||||||
/// LRU cache of decompressed chunk data.
|
/// LRU cache of decompressed chunk data.
|
||||||
slots: Vec<CachedChunk>,
|
slots: Vec<CachedChunk>,
|
||||||
|
|
||||||
|
/// Coordinate -> index into `slots`, for O(1) lookup instead of a linear
|
||||||
|
/// scan. Kept in sync with `slots` on every insert/evict/clear — in
|
||||||
|
/// particular, `slots.swap_remove(i)` moves the last element into slot
|
||||||
|
/// `i`, so the moved element's index entry must be updated too.
|
||||||
|
slot_index: HashMap<ChunkCoord, usize>,
|
||||||
|
|
||||||
/// Current total bytes of cached decompressed data.
|
/// Current total bytes of cached decompressed data.
|
||||||
current_bytes: usize,
|
current_bytes: usize,
|
||||||
|
|
||||||
@@ -334,7 +357,9 @@ impl ChunkCache {
|
|||||||
Self {
|
Self {
|
||||||
inner: std::sync::Mutex::new(CacheInner {
|
inner: std::sync::Mutex::new(CacheInner {
|
||||||
index: None,
|
index: None,
|
||||||
|
index_addr: None,
|
||||||
slots: Vec::with_capacity(max_slots.min(64)),
|
slots: Vec::with_capacity(max_slots.min(64)),
|
||||||
|
slot_index: HashMap::with_capacity(max_slots.min(64)),
|
||||||
current_bytes: 0,
|
current_bytes: 0,
|
||||||
max_bytes,
|
max_bytes,
|
||||||
max_slots,
|
max_slots,
|
||||||
@@ -349,6 +374,30 @@ impl ChunkCache {
|
|||||||
|
|
||||||
// ----- Index operations -----
|
// ----- Index operations -----
|
||||||
|
|
||||||
|
/// Bind the cache to the dataset at chunk-index address `addr`.
|
||||||
|
///
|
||||||
|
/// The cache is shared per file across all of its datasets. If the cache
|
||||||
|
/// currently holds state for a different dataset, all per-dataset state
|
||||||
|
/// (chunk index, chunk-index map, layout, and decompressed slots) is
|
||||||
|
/// dropped so the next access rebuilds it for this dataset. Reading the
|
||||||
|
/// same dataset again is a no-op, preserving the cache's benefit for
|
||||||
|
/// repeated/sequential access. Returns `true` if a reset occurred.
|
||||||
|
pub fn ensure_dataset(&self, addr: u64) -> bool {
|
||||||
|
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if inner.index_addr == Some(addr) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
inner.index = None;
|
||||||
|
inner.chunk_index = None;
|
||||||
|
inner.chunk_layout = None;
|
||||||
|
inner.slots.clear();
|
||||||
|
inner.slot_index.clear();
|
||||||
|
inner.current_bytes = 0;
|
||||||
|
inner.last_coord = None;
|
||||||
|
inner.index_addr = Some(addr);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `true` if the chunk index has been built.
|
/// Returns `true` if the chunk index has been built.
|
||||||
pub fn has_index(&self) -> bool {
|
pub fn has_index(&self) -> bool {
|
||||||
self.inner
|
self.inner
|
||||||
@@ -445,8 +494,20 @@ impl ChunkCache {
|
|||||||
|
|
||||||
/// Try to get cached decompressed data for a chunk coordinate.
|
/// Try to get cached decompressed data for a chunk coordinate.
|
||||||
///
|
///
|
||||||
/// Returns a clone of the cache-line-aligned buffer.
|
/// O(1) lookup. Returns an owned copy for API compatibility with callers
|
||||||
|
/// that need a `Vec<u8>`; prefer [`Self::get_decompressed_aligned`] when
|
||||||
|
/// an `Arc`-shared buffer works for the caller, since that avoids the
|
||||||
|
/// copy entirely.
|
||||||
pub fn get_decompressed(&self, coord: &[u64]) -> Option<Vec<u8>> {
|
pub fn get_decompressed(&self, coord: &[u64]) -> Option<Vec<u8>> {
|
||||||
|
self.get_decompressed_aligned(coord)
|
||||||
|
.map(|arc| arc.as_slice().to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to get a reference-counted clone of the aligned buffer for a chunk.
|
||||||
|
///
|
||||||
|
/// O(1) index lookup; the clone is an `Arc` refcount bump, not a copy of
|
||||||
|
/// the underlying decompressed data.
|
||||||
|
pub fn get_decompressed_aligned(&self, coord: &[u64]) -> Option<Arc<CacheAlignedBuffer>> {
|
||||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
inner.tick += 1;
|
inner.tick += 1;
|
||||||
let tick = inner.tick;
|
let tick = inner.tick;
|
||||||
@@ -468,36 +529,12 @@ impl ChunkCache {
|
|||||||
}
|
}
|
||||||
inner.last_coord = Some(coord.to_vec());
|
inner.last_coord = Some(coord.to_vec());
|
||||||
|
|
||||||
let mut found = None;
|
let found = if let Some(&idx) = inner.slot_index.get(coord) {
|
||||||
for slot in inner.slots.iter_mut() {
|
inner.slots[idx].last_access = tick;
|
||||||
if slot.coord.as_slice() == coord {
|
Some(Arc::clone(&inner.slots[idx].data))
|
||||||
slot.last_access = tick;
|
|
||||||
found = Some(slot.data.to_vec());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(ref data) = found {
|
|
||||||
inner.stats.hits += 1;
|
|
||||||
inner.stats.bytes_read += data.len() as u64;
|
|
||||||
} else {
|
} else {
|
||||||
inner.stats.misses += 1;
|
None
|
||||||
}
|
};
|
||||||
found
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to get a reference-counted clone of the aligned buffer for a chunk.
|
|
||||||
pub fn get_decompressed_aligned(&self, coord: &[u64]) -> Option<CacheAlignedBuffer> {
|
|
||||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
inner.tick += 1;
|
|
||||||
let tick = inner.tick;
|
|
||||||
let mut found = None;
|
|
||||||
for slot in inner.slots.iter_mut() {
|
|
||||||
if slot.coord.as_slice() == coord {
|
|
||||||
slot.last_access = tick;
|
|
||||||
found = Some(slot.data.clone());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(ref data) = found {
|
if let Some(ref data) = found {
|
||||||
inner.stats.hits += 1;
|
inner.stats.hits += 1;
|
||||||
inner.stats.bytes_read += data.len() as u64;
|
inner.stats.bytes_read += data.len() as u64;
|
||||||
@@ -510,30 +547,39 @@ impl ChunkCache {
|
|||||||
/// Insert decompressed chunk data into the LRU cache.
|
/// Insert decompressed chunk data into the LRU cache.
|
||||||
///
|
///
|
||||||
/// The data is stored in a [`CacheAlignedBuffer`] so subsequent reads
|
/// The data is stored in a [`CacheAlignedBuffer`] so subsequent reads
|
||||||
/// return cache-line-aligned memory.
|
/// return cache-line-aligned memory. Returns the `Arc`-shared buffer that
|
||||||
pub fn put_decompressed(&self, coord: ChunkCoord, data: Vec<u8>) {
|
/// is now cached (or already was), so the caller can reuse it directly
|
||||||
let aligned = CacheAlignedBuffer::from_slice(&data);
|
/// instead of holding a separate copy of the same data.
|
||||||
self.put_decompressed_aligned(coord, aligned);
|
pub fn put_decompressed(&self, coord: ChunkCoord, data: Vec<u8>) -> Arc<CacheAlignedBuffer> {
|
||||||
|
let aligned = CacheAlignedBuffer::from_vec(data);
|
||||||
|
self.put_decompressed_aligned(coord, aligned)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert an already-aligned buffer into the LRU cache.
|
/// Insert an already-aligned buffer into the LRU cache.
|
||||||
pub fn put_decompressed_aligned(&self, coord: ChunkCoord, data: CacheAlignedBuffer) {
|
///
|
||||||
|
/// Returns the `Arc`-shared buffer now held by the cache (the one just
|
||||||
|
/// inserted, or the existing cached copy if `coord` was already present).
|
||||||
|
pub fn put_decompressed_aligned(
|
||||||
|
&self,
|
||||||
|
coord: ChunkCoord,
|
||||||
|
data: CacheAlignedBuffer,
|
||||||
|
) -> Arc<CacheAlignedBuffer> {
|
||||||
|
let data = Arc::new(data);
|
||||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let data_len = data.len();
|
let data_len = data.len();
|
||||||
|
|
||||||
// Don't cache if single chunk exceeds budget
|
// Don't cache if single chunk exceeds budget — still return the data
|
||||||
|
// to the caller, just don't retain it.
|
||||||
if data_len > inner.max_bytes {
|
if data_len > inner.max_bytes {
|
||||||
return;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already present
|
// Check if already present
|
||||||
inner.tick += 1;
|
inner.tick += 1;
|
||||||
let tick = inner.tick;
|
let tick = inner.tick;
|
||||||
for slot in inner.slots.iter_mut() {
|
if let Some(&idx) = inner.slot_index.get(&coord) {
|
||||||
if slot.coord == coord {
|
inner.slots[idx].last_access = tick;
|
||||||
slot.last_access = tick;
|
return Arc::clone(&inner.slots[idx].data); // already cached
|
||||||
return; // already cached
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evict until we have room
|
// Evict until we have room
|
||||||
@@ -549,23 +595,35 @@ impl ChunkCache {
|
|||||||
.map(|(i, _)| i)
|
.map(|(i, _)| i)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let removed = inner.slots.swap_remove(lru_idx);
|
let removed = inner.slots.swap_remove(lru_idx);
|
||||||
|
inner.slot_index.remove(&removed.coord);
|
||||||
|
// swap_remove moved the former last element into `lru_idx` (unless
|
||||||
|
// it *was* the last element) — fix up that element's index entry.
|
||||||
|
if lru_idx < inner.slots.len() {
|
||||||
|
let moved_coord = inner.slots[lru_idx].coord.clone();
|
||||||
|
inner.slot_index.insert(moved_coord, lru_idx);
|
||||||
|
}
|
||||||
inner.current_bytes -= removed.data.len();
|
inner.current_bytes -= removed.data.len();
|
||||||
inner.stats.evictions += 1;
|
inner.stats.evictions += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
inner.current_bytes += data_len;
|
inner.current_bytes += data_len;
|
||||||
|
let new_idx = inner.slots.len();
|
||||||
|
inner.slot_index.insert(coord.clone(), new_idx);
|
||||||
inner.slots.push(CachedChunk {
|
inner.slots.push(CachedChunk {
|
||||||
coord,
|
coord,
|
||||||
data,
|
data: Arc::clone(&data),
|
||||||
last_access: tick,
|
last_access: tick,
|
||||||
});
|
});
|
||||||
|
data
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the entire cache (index + decompressed data).
|
/// Clear the entire cache (index + decompressed data).
|
||||||
pub fn clear(&self) {
|
pub fn clear(&self) {
|
||||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
inner.index = None;
|
inner.index = None;
|
||||||
|
inner.index_addr = None;
|
||||||
inner.slots.clear();
|
inner.slots.clear();
|
||||||
|
inner.slot_index.clear();
|
||||||
inner.current_bytes = 0;
|
inner.current_bytes = 0;
|
||||||
inner.tick = 0;
|
inner.tick = 0;
|
||||||
inner.last_coord = None;
|
inner.last_coord = None;
|
||||||
@@ -574,11 +632,13 @@ impl ChunkCache {
|
|||||||
inner.chunk_layout = None;
|
inner.chunk_layout = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hint that the given chunk coordinates will be accessed soon.
|
/// Record that the given chunk coordinates are predicted to be accessed
|
||||||
|
/// soon (bookkeeping only).
|
||||||
///
|
///
|
||||||
/// Pre-populates the chunk index for these coordinates so that
|
/// This does **not** prefetch or pre-decompress anything — it only
|
||||||
/// subsequent lookups are O(1). This does NOT pre-decompress the
|
/// checks whether each coordinate is already in the chunk index and
|
||||||
/// chunks — it only ensures the index entries exist.
|
/// updates access-pattern stats accordingly. Real prefetching (e.g.
|
||||||
|
/// background pre-decompression) is not implemented.
|
||||||
pub fn prefetch_hint(&self, next_coords: &[ChunkCoord]) {
|
pub fn prefetch_hint(&self, next_coords: &[ChunkCoord]) {
|
||||||
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
if inner.index.is_none() {
|
if inner.index.is_none() {
|
||||||
@@ -752,6 +812,50 @@ mod tests {
|
|||||||
assert_eq!(cache.cached_bytes(), 3);
|
assert_eq!(cache.cached_bytes(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slot_index_consistent_after_many_evictions() {
|
||||||
|
// Force repeated swap_remove evictions (small slot budget, many
|
||||||
|
// inserts) and confirm the coord -> slot index stays correct: every
|
||||||
|
// remaining coord must still resolve to its own data, not another
|
||||||
|
// slot's (which would happen if swap_remove's index fixup were wrong).
|
||||||
|
let cache = ChunkCache::with_capacity(1024 * 1024, 4); // max 4 slots
|
||||||
|
|
||||||
|
for i in 0..50u64 {
|
||||||
|
cache.put_decompressed(vec![i], vec![(i % 256) as u8; 8]);
|
||||||
|
// Interleave reads of a couple of earlier coords to churn LRU
|
||||||
|
// order (and thus which slot gets swap_remove'd) beyond simple
|
||||||
|
// FIFO eviction.
|
||||||
|
if i >= 2 {
|
||||||
|
let _ = cache.get_decompressed(&[i - 2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever remains in the cache (at most 4 slots) must return its
|
||||||
|
// own correct data.
|
||||||
|
for i in 0..50u64 {
|
||||||
|
if let Some(data) = cache.get_decompressed(&[i]) {
|
||||||
|
assert_eq!(
|
||||||
|
data,
|
||||||
|
vec![(i % 256) as u8; 8],
|
||||||
|
"coord {i} returned wrong data after eviction churn"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(cache.cached_chunk_count() <= 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_decompressed_aligned_shares_arc_on_hit() {
|
||||||
|
let cache = ChunkCache::new();
|
||||||
|
cache.put_decompressed(vec![0, 0], vec![9, 9, 9, 9]);
|
||||||
|
let a = cache.get_decompressed_aligned(&[0, 0]).unwrap();
|
||||||
|
let b = cache.get_decompressed_aligned(&[0, 0]).unwrap();
|
||||||
|
// A cache hit clones the Arc (refcount bump), not the underlying
|
||||||
|
// buffer — both handles point at the same allocation.
|
||||||
|
assert!(Arc::ptr_eq(&a, &b));
|
||||||
|
assert_eq!(a.as_slice(), &[9, 9, 9, 9]);
|
||||||
|
}
|
||||||
|
|
||||||
// --- CacheAlignedBuffer tests ---
|
// --- CacheAlignedBuffer tests ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ use crate::extensible_array::{ExtensibleArrayHeader, read_extensible_array_chunk
|
|||||||
use crate::filter_pipeline::FilterPipeline;
|
use crate::filter_pipeline::FilterPipeline;
|
||||||
use crate::filters::decompress_chunk;
|
use crate::filters::decompress_chunk;
|
||||||
use crate::fixed_array::{FixedArrayHeader, read_fixed_array_chunks};
|
use crate::fixed_array::{FixedArrayHeader, read_fixed_array_chunks};
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[cfg(feature = "parallel")]
|
#[cfg(feature = "parallel")]
|
||||||
use crate::parallel_read;
|
use crate::parallel_read;
|
||||||
@@ -593,6 +595,10 @@ pub fn read_chunked_data_cached(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The per-file cache is shared across datasets; bind it to this one so a
|
||||||
|
// different dataset's chunk index is never reused for this read.
|
||||||
|
cache.ensure_dataset(addr);
|
||||||
|
|
||||||
// Populate chunk index on first access
|
// Populate chunk index on first access
|
||||||
if !cache.has_index() {
|
if !cache.has_index() {
|
||||||
let chunks = match (version, chunk_index_type) {
|
let chunks = match (version, chunk_index_type) {
|
||||||
@@ -685,7 +691,7 @@ pub fn read_chunked_data_cached(
|
|||||||
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
|
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
|
||||||
|
|
||||||
// Try decompressed cache first
|
// Try decompressed cache first
|
||||||
let decompressed = if let Some(cached) = cache.get_decompressed(&coord) {
|
let decompressed = if let Some(cached) = cache.get_decompressed_aligned(&coord) {
|
||||||
cached
|
cached
|
||||||
} else {
|
} else {
|
||||||
// Decompress from file
|
// Decompress from file
|
||||||
@@ -707,8 +713,7 @@ pub fn read_chunked_data_cached(
|
|||||||
} else {
|
} else {
|
||||||
raw_chunk.to_vec()
|
raw_chunk.to_vec()
|
||||||
};
|
};
|
||||||
cache.put_decompressed(coord, dec.clone());
|
cache.put_decompressed(coord, dec)
|
||||||
dec
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let chunk_offsets: Vec<usize> = chunk_info
|
let chunk_offsets: Vec<usize> = chunk_info
|
||||||
@@ -946,6 +951,10 @@ pub fn read_chunked_data_sweep(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The per-file cache is shared across datasets; bind it to this one so a
|
||||||
|
// different dataset's chunk index is never reused for this read.
|
||||||
|
cache.ensure_dataset(addr);
|
||||||
|
|
||||||
// Populate chunk index on first access
|
// Populate chunk index on first access
|
||||||
if !cache.has_index() {
|
if !cache.has_index() {
|
||||||
let chunks = match (version, chunk_index_type) {
|
let chunks = match (version, chunk_index_type) {
|
||||||
@@ -1047,7 +1056,7 @@ pub fn read_chunked_data_sweep(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try decompressed cache first
|
// Try decompressed cache first
|
||||||
let decompressed = if let Some(cached) = cache.get_decompressed(&coord) {
|
let decompressed = if let Some(cached) = cache.get_decompressed_aligned(&coord) {
|
||||||
cached
|
cached
|
||||||
} else {
|
} else {
|
||||||
// Decompress from file
|
// Decompress from file
|
||||||
@@ -1069,8 +1078,7 @@ pub fn read_chunked_data_sweep(
|
|||||||
} else {
|
} else {
|
||||||
raw_chunk.to_vec()
|
raw_chunk.to_vec()
|
||||||
};
|
};
|
||||||
cache.put_decompressed(coord, dec.clone());
|
cache.put_decompressed(coord, dec)
|
||||||
dec
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let chunk_offsets: Vec<usize> = chunk_info
|
let chunk_offsets: Vec<usize> = chunk_info
|
||||||
@@ -1169,6 +1177,10 @@ pub fn read_chunked_data_indexed(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The per-file cache is shared across datasets; bind it to this one so a
|
||||||
|
// different dataset's chunk index is never reused for this read.
|
||||||
|
cache.ensure_dataset(addr);
|
||||||
|
|
||||||
// Build chunk index on first access
|
// Build chunk index on first access
|
||||||
if !cache.has_chunk_index() {
|
if !cache.has_chunk_index() {
|
||||||
let chunks = match (version, chunk_index_type) {
|
let chunks = match (version, chunk_index_type) {
|
||||||
@@ -1259,7 +1271,7 @@ pub fn read_chunked_data_indexed(
|
|||||||
.ok_or_else(|| FormatError::ChunkedReadError("chunk layout not available".into()))?;
|
.ok_or_else(|| FormatError::ChunkedReadError("chunk layout not available".into()))?;
|
||||||
|
|
||||||
// Decompress chunks (using LRU cache where possible)
|
// Decompress chunks (using LRU cache where possible)
|
||||||
let mut chunk_buffers: Vec<CacheAlignedBuffer> = Vec::with_capacity(mappings_info.len());
|
let mut chunk_buffers: Vec<Arc<CacheAlignedBuffer>> = Vec::with_capacity(mappings_info.len());
|
||||||
for (coord, file_offset, file_size, filter_mask) in &mappings_info {
|
for (coord, file_offset, file_size, filter_mask) in &mappings_info {
|
||||||
if let Some(cached) = cache.get_decompressed_aligned(coord) {
|
if let Some(cached) = cache.get_decompressed_aligned(coord) {
|
||||||
chunk_buffers.push(cached);
|
chunk_buffers.push(cached);
|
||||||
@@ -1283,8 +1295,8 @@ pub fn read_chunked_data_indexed(
|
|||||||
raw_chunk.to_vec()
|
raw_chunk.to_vec()
|
||||||
};
|
};
|
||||||
let aligned = CacheAlignedBuffer::from_vec(decompressed);
|
let aligned = CacheAlignedBuffer::from_vec(decompressed);
|
||||||
cache.put_decompressed_aligned(coord.clone(), aligned.clone());
|
let arc = cache.put_decompressed_aligned(coord.clone(), aligned);
|
||||||
chunk_buffers.push(aligned);
|
chunk_buffers.push(arc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ use crate::chunk_cache::{CACHE_LINE_SIZE, align_to_cache_line};
|
|||||||
use crate::ea_writer;
|
use crate::ea_writer;
|
||||||
use crate::error::FormatError;
|
use crate::error::FormatError;
|
||||||
use crate::filter_pipeline::{
|
use crate::filter_pipeline::{
|
||||||
FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_SHUFFLE, FILTER_ZSTD, FilterDescription,
|
FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_PCODEC, FILTER_SHUFFLE, FILTER_ZSTD,
|
||||||
FilterPipeline,
|
FilterDescription, FilterPipeline,
|
||||||
};
|
};
|
||||||
use crate::filters::compress_chunk;
|
use crate::filters::compress_chunk;
|
||||||
|
|
||||||
@@ -34,13 +34,19 @@ pub struct ChunkOptions {
|
|||||||
/// Deflate compression level (0-9), None = no deflate.
|
/// Deflate compression level (0-9), None = no deflate.
|
||||||
pub deflate_level: Option<u32>,
|
pub deflate_level: Option<u32>,
|
||||||
/// Whether to apply shuffle filter before compression.
|
/// Whether to apply shuffle filter before compression.
|
||||||
|
/// If `false` AND compression is enabled AND `no_shuffle` is `false`,
|
||||||
|
/// shuffle is auto-applied (matches h5py default behavior).
|
||||||
pub shuffle: bool,
|
pub shuffle: bool,
|
||||||
|
/// Disable the automatic shuffle pre-filter. Set via `without_shuffle()`.
|
||||||
|
pub no_shuffle: bool,
|
||||||
/// Whether to apply fletcher32 checksum.
|
/// Whether to apply fletcher32 checksum.
|
||||||
pub fletcher32: bool,
|
pub fletcher32: bool,
|
||||||
/// Whether to use LZ4 compression (filter ID 32004).
|
/// Whether to use LZ4 compression (filter ID 32004).
|
||||||
pub lz4: bool,
|
pub lz4: bool,
|
||||||
/// Zstandard compression level (1-22), None = no zstd. Filter ID 32015.
|
/// Zstandard compression level (1-22), None = no zstd. Filter ID 32015.
|
||||||
pub zstd_level: Option<u32>,
|
pub zstd_level: Option<u32>,
|
||||||
|
/// Pcodec lossless numerical compression. Filter ID 32023.
|
||||||
|
pub pcodec: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChunkOptions {
|
impl ChunkOptions {
|
||||||
@@ -52,13 +58,20 @@ impl ChunkOptions {
|
|||||||
|| self.fletcher32
|
|| self.fletcher32
|
||||||
|| self.lz4
|
|| self.lz4
|
||||||
|| self.zstd_level.is_some()
|
|| self.zstd_level.is_some()
|
||||||
|
|| self.pcodec
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a FilterPipeline from the options.
|
/// Build a FilterPipeline from the options.
|
||||||
pub fn build_pipeline(&self, element_size: u32) -> Option<FilterPipeline> {
|
pub fn build_pipeline(&self, element_size: u32) -> Option<FilterPipeline> {
|
||||||
let mut filters = Vec::new();
|
let mut filters = Vec::new();
|
||||||
|
|
||||||
if self.shuffle {
|
let has_compression =
|
||||||
|
self.deflate_level.is_some() || self.zstd_level.is_some() || self.lz4 || self.pcodec;
|
||||||
|
|
||||||
|
// Shuffle before compression. Applied if explicitly requested OR if compression
|
||||||
|
// is active and the caller hasn't disabled it — matches h5py default behavior
|
||||||
|
// and implements TDT byte-grouping (arXiv:2506.18062) for free.
|
||||||
|
if self.shuffle || (has_compression && !self.no_shuffle) {
|
||||||
filters.push(FilterDescription {
|
filters.push(FilterDescription {
|
||||||
filter_id: FILTER_SHUFFLE,
|
filter_id: FILTER_SHUFFLE,
|
||||||
name: None,
|
name: None,
|
||||||
@@ -67,8 +80,15 @@ impl ChunkOptions {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compression filters (mutually exclusive, priority: zstd > lz4 > deflate)
|
// Compression filters (mutually exclusive, priority: pcodec > zstd > lz4 > deflate)
|
||||||
if let Some(level) = self.zstd_level {
|
if self.pcodec {
|
||||||
|
filters.push(FilterDescription {
|
||||||
|
filter_id: FILTER_PCODEC,
|
||||||
|
name: Some("pcodec".into()),
|
||||||
|
flags: 0,
|
||||||
|
client_data: vec![element_size],
|
||||||
|
});
|
||||||
|
} else if let Some(level) = self.zstd_level {
|
||||||
filters.push(FilterDescription {
|
filters.push(FilterDescription {
|
||||||
filter_id: FILTER_ZSTD,
|
filter_id: FILTER_ZSTD,
|
||||||
name: Some("zstd".into()),
|
name: Some("zstd".into()),
|
||||||
@@ -238,11 +258,19 @@ pub fn split_into_chunks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parallel compression threshold: use rayon when chunk count exceeds this.
|
/// Parallel compression threshold: use rayon when chunk count exceeds this.
|
||||||
#[allow(dead_code)]
|
///
|
||||||
const PARALLEL_COMPRESS_THRESHOLD: usize = 4;
|
/// Lowered to 2 to enable parallel compression for typical 4-chunk workloads
|
||||||
|
/// (e.g., 128×128 matrix with 32-row chunks = 4 chunks). Rayon's overhead is
|
||||||
|
/// ~2 µs, worthwhile at ≥2 chunks with any real compression (arXiv:2206.14761).
|
||||||
|
#[cfg(feature = "parallel")]
|
||||||
|
const PARALLEL_COMPRESS_THRESHOLD: usize = 2;
|
||||||
|
|
||||||
/// Compress all chunks, using parallel compression when beneficial.
|
/// Compress all chunks, using parallel compression when beneficial.
|
||||||
#[allow(dead_code)]
|
///
|
||||||
|
/// With the `parallel` feature and more than [`PARALLEL_COMPRESS_THRESHOLD`]
|
||||||
|
/// filtered chunks, compression runs across rayon threads; otherwise it is
|
||||||
|
/// sequential. Output order matches input order, so per-chunk bytes are
|
||||||
|
/// identical to the sequential path.
|
||||||
fn compress_all_chunks(
|
fn compress_all_chunks(
|
||||||
chunks: &[(Vec<u64>, Vec<u8>)],
|
chunks: &[(Vec<u64>, Vec<u8>)],
|
||||||
pipeline: &Option<FilterPipeline>,
|
pipeline: &Option<FilterPipeline>,
|
||||||
@@ -541,6 +569,158 @@ pub fn build_fixed_array_at(
|
|||||||
combined
|
combined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compressed chunks ready to be laid out at any file address.
|
||||||
|
///
|
||||||
|
/// Created by [`precompress_chunks`] and consumed by
|
||||||
|
/// [`build_chunked_data_from_precompressed`]. Caching this between the two
|
||||||
|
/// writer passes eliminates the double-compression that the two-pass layout
|
||||||
|
/// algorithm previously performed.
|
||||||
|
pub struct PrecompressedChunks {
|
||||||
|
/// Per-chunk: (raw_size_bytes, compressed_bytes).
|
||||||
|
pub chunks: Vec<(u64, Vec<u8>)>,
|
||||||
|
pub has_filters: bool,
|
||||||
|
pub element_size: usize,
|
||||||
|
pub shape: Vec<u64>,
|
||||||
|
pub chunk_dims: Vec<u64>,
|
||||||
|
pub pipeline_message: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compress all chunks of a dataset without laying them out at a file address.
|
||||||
|
///
|
||||||
|
/// Call this once per dataset in Pass 1, cache the result, then call
|
||||||
|
/// [`build_chunked_data_from_precompressed`] in both Pass 1 (dummy address
|
||||||
|
/// for sizing) and Pass 2 (real address) to avoid re-compressing.
|
||||||
|
pub fn precompress_chunks(
|
||||||
|
raw_data: &[u8],
|
||||||
|
shape: &[u64],
|
||||||
|
chunk_dims: &[u64],
|
||||||
|
element_size: usize,
|
||||||
|
options: &ChunkOptions,
|
||||||
|
) -> Result<PrecompressedChunks, FormatError> {
|
||||||
|
let pipeline = options.build_pipeline(element_size as u32);
|
||||||
|
let has_filters = pipeline.is_some();
|
||||||
|
let pipeline_message = pipeline.as_ref().map(|pl| pl.serialize());
|
||||||
|
|
||||||
|
let raw_chunks = split_into_chunks(raw_data, shape, chunk_dims, element_size);
|
||||||
|
let compressed = compress_all_chunks(&raw_chunks, &pipeline, element_size as u32)?;
|
||||||
|
|
||||||
|
let chunks = raw_chunks
|
||||||
|
.into_iter()
|
||||||
|
.zip(compressed)
|
||||||
|
.map(|((_offsets, raw_bytes), c)| (raw_bytes.len() as u64, c))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(PrecompressedChunks {
|
||||||
|
chunks,
|
||||||
|
has_filters,
|
||||||
|
element_size,
|
||||||
|
shape: shape.to_vec(),
|
||||||
|
chunk_dims: chunk_dims.to_vec(),
|
||||||
|
pipeline_message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lay out precompressed chunks at `base_address` and build index structures.
|
||||||
|
///
|
||||||
|
/// This is the address-dependent half of chunk writing. Call it in Pass 1
|
||||||
|
/// with a dummy address (to get the blob size), and again in Pass 2 with the
|
||||||
|
/// real address — both times reusing the same [`PrecompressedChunks`] so
|
||||||
|
/// compression happens only once.
|
||||||
|
pub fn build_chunked_data_from_precompressed(
|
||||||
|
pre: &PrecompressedChunks,
|
||||||
|
base_address: u64,
|
||||||
|
maxshape: Option<&[u64]>,
|
||||||
|
) -> ChunkedDataResult {
|
||||||
|
let offset_size: u8 = 8;
|
||||||
|
let length_size: u8 = 8;
|
||||||
|
let num_chunks = pre.chunks.len();
|
||||||
|
let element_size = pre.element_size;
|
||||||
|
|
||||||
|
let mut data_buf = Vec::new();
|
||||||
|
let mut written_chunks = Vec::with_capacity(num_chunks);
|
||||||
|
|
||||||
|
for (raw_size, compressed) in &pre.chunks {
|
||||||
|
let aligned_offset = align_to_cache_line(data_buf.len());
|
||||||
|
if aligned_offset > data_buf.len() {
|
||||||
|
data_buf.resize(aligned_offset, 0u8);
|
||||||
|
}
|
||||||
|
let address = base_address + data_buf.len() as u64;
|
||||||
|
let compressed_size = compressed.len() as u64;
|
||||||
|
data_buf.extend_from_slice(compressed);
|
||||||
|
written_chunks.push(WrittenChunk {
|
||||||
|
address,
|
||||||
|
compressed_size,
|
||||||
|
raw_size: *raw_size,
|
||||||
|
filter_mask: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk_dims_u32: Vec<u32> = pre.chunk_dims.iter().map(|&d| d as u32).collect();
|
||||||
|
let use_extensible = maxshape.is_some_and(|ms| ms.contains(&u64::MAX));
|
||||||
|
|
||||||
|
let aligned_idx = align_to_cache_line(data_buf.len());
|
||||||
|
if aligned_idx > data_buf.len() {
|
||||||
|
data_buf.resize(aligned_idx, 0u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
let layout_message = if use_extensible {
|
||||||
|
let ea_address = base_address + data_buf.len() as u64;
|
||||||
|
let ea_bytes = ea_writer::build_extensible_array_at(
|
||||||
|
&written_chunks,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
pre.has_filters,
|
||||||
|
ea_address,
|
||||||
|
);
|
||||||
|
data_buf.extend_from_slice(&ea_bytes);
|
||||||
|
ea_writer::serialize_v4_extensible_array(
|
||||||
|
&chunk_dims_u32,
|
||||||
|
ea_address,
|
||||||
|
offset_size,
|
||||||
|
element_size as u32,
|
||||||
|
)
|
||||||
|
} else if num_chunks == 1 {
|
||||||
|
let chunk_addr = written_chunks[0].address;
|
||||||
|
let filtered_size = if pre.has_filters {
|
||||||
|
Some(written_chunks[0].compressed_size)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let filter_mask = if pre.has_filters { Some(0u32) } else { None };
|
||||||
|
serialize_v4_single_chunk(
|
||||||
|
&chunk_dims_u32,
|
||||||
|
chunk_addr,
|
||||||
|
filtered_size,
|
||||||
|
filter_mask,
|
||||||
|
offset_size,
|
||||||
|
element_size as u32,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let fa_address = base_address + data_buf.len() as u64;
|
||||||
|
let fa_bytes = build_fixed_array_at(
|
||||||
|
&written_chunks,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
pre.has_filters,
|
||||||
|
fa_address,
|
||||||
|
);
|
||||||
|
data_buf.extend_from_slice(&fa_bytes);
|
||||||
|
serialize_v4_fixed_array(
|
||||||
|
&chunk_dims_u32,
|
||||||
|
fa_address,
|
||||||
|
offset_size,
|
||||||
|
element_size as u32,
|
||||||
|
10, // max_nelmts_bits — matches h5py convention
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
ChunkedDataResult {
|
||||||
|
data_bytes: data_buf,
|
||||||
|
layout_message,
|
||||||
|
pipeline_message: pre.pipeline_message.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build chunked data with absolute addresses.
|
/// Build chunked data with absolute addresses.
|
||||||
/// If `maxshape` has unlimited dims, uses Extensible Array index.
|
/// If `maxshape` has unlimited dims, uses Extensible Array index.
|
||||||
pub fn build_chunked_data_at(
|
pub fn build_chunked_data_at(
|
||||||
@@ -572,119 +752,12 @@ pub fn build_chunked_data_at_ext(
|
|||||||
base_address: u64,
|
base_address: u64,
|
||||||
maxshape: Option<&[u64]>,
|
maxshape: Option<&[u64]>,
|
||||||
) -> Result<ChunkedDataResult, FormatError> {
|
) -> Result<ChunkedDataResult, FormatError> {
|
||||||
let pipeline = options.build_pipeline(element_size as u32);
|
let pre = precompress_chunks(raw_data, shape, chunk_dims, element_size, options)?;
|
||||||
|
Ok(build_chunked_data_from_precompressed(
|
||||||
let chunks = split_into_chunks(raw_data, shape, chunk_dims, element_size);
|
&pre,
|
||||||
let num_chunks = chunks.len();
|
base_address,
|
||||||
let has_filters = pipeline.is_some();
|
maxshape,
|
||||||
|
))
|
||||||
// Compress each chunk, padding to cache-line boundaries for aligned access
|
|
||||||
let mut data_buf = Vec::new();
|
|
||||||
let mut written_chunks = Vec::with_capacity(num_chunks);
|
|
||||||
|
|
||||||
for (_offsets, chunk_bytes) in &chunks {
|
|
||||||
let compressed = if let Some(pl) = pipeline.as_ref() {
|
|
||||||
compress_chunk(chunk_bytes, pl, element_size as u32)?
|
|
||||||
} else {
|
|
||||||
chunk_bytes.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pad current position to cache-line boundary
|
|
||||||
let aligned_offset = align_to_cache_line(data_buf.len());
|
|
||||||
if aligned_offset > data_buf.len() {
|
|
||||||
data_buf.resize(aligned_offset, 0u8);
|
|
||||||
}
|
|
||||||
|
|
||||||
let address = base_address + data_buf.len() as u64;
|
|
||||||
let compressed_size = compressed.len() as u64;
|
|
||||||
let raw_size = chunk_bytes.len() as u64;
|
|
||||||
|
|
||||||
data_buf.extend_from_slice(&compressed);
|
|
||||||
|
|
||||||
written_chunks.push(WrittenChunk {
|
|
||||||
address,
|
|
||||||
compressed_size,
|
|
||||||
raw_size,
|
|
||||||
filter_mask: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let chunk_dims_u32: Vec<u32> = chunk_dims.iter().map(|&d| d as u32).collect();
|
|
||||||
let offset_size: u8 = 8;
|
|
||||||
let length_size: u8 = 8;
|
|
||||||
|
|
||||||
// Determine if we should use Extensible Array (resizable datasets)
|
|
||||||
let use_extensible = maxshape.is_some_and(|ms| ms.contains(&u64::MAX));
|
|
||||||
|
|
||||||
// Pad before index structures so they are also cache-line aligned
|
|
||||||
let aligned_idx = align_to_cache_line(data_buf.len());
|
|
||||||
if aligned_idx > data_buf.len() {
|
|
||||||
data_buf.resize(aligned_idx, 0u8);
|
|
||||||
}
|
|
||||||
|
|
||||||
let layout_message = if use_extensible {
|
|
||||||
let ea_address = base_address + data_buf.len() as u64;
|
|
||||||
|
|
||||||
let ea_bytes = ea_writer::build_extensible_array_at(
|
|
||||||
&written_chunks,
|
|
||||||
offset_size,
|
|
||||||
length_size,
|
|
||||||
has_filters,
|
|
||||||
ea_address,
|
|
||||||
);
|
|
||||||
data_buf.extend_from_slice(&ea_bytes);
|
|
||||||
|
|
||||||
ea_writer::serialize_v4_extensible_array(
|
|
||||||
&chunk_dims_u32,
|
|
||||||
ea_address,
|
|
||||||
offset_size,
|
|
||||||
element_size as u32,
|
|
||||||
)
|
|
||||||
} else if num_chunks == 1 {
|
|
||||||
let chunk_addr = written_chunks[0].address;
|
|
||||||
let filtered_size = if has_filters {
|
|
||||||
Some(written_chunks[0].compressed_size)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let filter_mask = if has_filters { Some(0u32) } else { None };
|
|
||||||
serialize_v4_single_chunk(
|
|
||||||
&chunk_dims_u32,
|
|
||||||
chunk_addr,
|
|
||||||
filtered_size,
|
|
||||||
filter_mask,
|
|
||||||
offset_size,
|
|
||||||
element_size as u32,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
let fa_address = base_address + data_buf.len() as u64;
|
|
||||||
let max_bits: u8 = 10;
|
|
||||||
|
|
||||||
let fa_bytes = build_fixed_array_at(
|
|
||||||
&written_chunks,
|
|
||||||
offset_size,
|
|
||||||
length_size,
|
|
||||||
has_filters,
|
|
||||||
fa_address,
|
|
||||||
);
|
|
||||||
data_buf.extend_from_slice(&fa_bytes);
|
|
||||||
|
|
||||||
serialize_v4_fixed_array(
|
|
||||||
&chunk_dims_u32,
|
|
||||||
fa_address,
|
|
||||||
offset_size,
|
|
||||||
element_size as u32,
|
|
||||||
max_bits,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let pipeline_message = pipeline.as_ref().map(|pl| pl.serialize());
|
|
||||||
|
|
||||||
Ok(ChunkedDataResult {
|
|
||||||
data_bytes: data_buf,
|
|
||||||
layout_message,
|
|
||||||
pipeline_message,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write selected elements into an existing in-memory dataset buffer.
|
/// Write selected elements into an existing in-memory dataset buffer.
|
||||||
@@ -1072,36 +1145,55 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chunk_options_pipeline_deflate() {
|
fn chunk_options_pipeline_deflate() {
|
||||||
|
// Auto-shuffle is applied before compression by default (matches h5py).
|
||||||
let options = ChunkOptions {
|
let options = ChunkOptions {
|
||||||
deflate_level: Some(6),
|
deflate_level: Some(6),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let pl = options.build_pipeline(8).unwrap();
|
let pl = options.build_pipeline(8).unwrap();
|
||||||
|
assert_eq!(pl.filters.len(), 2);
|
||||||
|
assert_eq!(pl.filters[0].filter_id, FILTER_SHUFFLE);
|
||||||
|
assert_eq!(pl.filters[1].filter_id, FILTER_DEFLATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chunk_options_pipeline_deflate_no_shuffle() {
|
||||||
|
// Users can opt out of auto-shuffle with no_shuffle = true.
|
||||||
|
let options = ChunkOptions {
|
||||||
|
deflate_level: Some(6),
|
||||||
|
no_shuffle: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let pl = options.build_pipeline(8).unwrap();
|
||||||
assert_eq!(pl.filters.len(), 1);
|
assert_eq!(pl.filters.len(), 1);
|
||||||
assert_eq!(pl.filters[0].filter_id, FILTER_DEFLATE);
|
assert_eq!(pl.filters[0].filter_id, FILTER_DEFLATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chunk_options_pipeline_lz4() {
|
fn chunk_options_pipeline_lz4() {
|
||||||
|
// Auto-shuffle before LZ4.
|
||||||
let options = ChunkOptions {
|
let options = ChunkOptions {
|
||||||
lz4: true,
|
lz4: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let pl = options.build_pipeline(8).unwrap();
|
let pl = options.build_pipeline(8).unwrap();
|
||||||
assert_eq!(pl.filters.len(), 1);
|
assert_eq!(pl.filters.len(), 2);
|
||||||
assert_eq!(pl.filters[0].filter_id, FILTER_LZ4);
|
assert_eq!(pl.filters[0].filter_id, FILTER_SHUFFLE);
|
||||||
|
assert_eq!(pl.filters[1].filter_id, FILTER_LZ4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chunk_options_pipeline_zstd() {
|
fn chunk_options_pipeline_zstd() {
|
||||||
|
// Auto-shuffle before Zstd.
|
||||||
let options = ChunkOptions {
|
let options = ChunkOptions {
|
||||||
zstd_level: Some(3),
|
zstd_level: Some(3),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let pl = options.build_pipeline(8).unwrap();
|
let pl = options.build_pipeline(8).unwrap();
|
||||||
assert_eq!(pl.filters.len(), 1);
|
assert_eq!(pl.filters.len(), 2);
|
||||||
assert_eq!(pl.filters[0].filter_id, FILTER_ZSTD);
|
assert_eq!(pl.filters[0].filter_id, FILTER_SHUFFLE);
|
||||||
assert_eq!(pl.filters[0].client_data, vec![3]);
|
assert_eq!(pl.filters[1].filter_id, FILTER_ZSTD);
|
||||||
|
assert_eq!(pl.filters[1].client_data, vec![3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1112,8 +1204,10 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let pl = options.build_pipeline(8).unwrap();
|
let pl = options.build_pipeline(8).unwrap();
|
||||||
assert_eq!(pl.filters.len(), 1);
|
// shuffle + zstd (deflate is ignored when zstd wins priority)
|
||||||
assert_eq!(pl.filters[0].filter_id, FILTER_ZSTD);
|
assert_eq!(pl.filters.len(), 2);
|
||||||
|
assert_eq!(pl.filters[0].filter_id, FILTER_SHUFFLE);
|
||||||
|
assert_eq!(pl.filters[1].filter_id, FILTER_ZSTD);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -67,74 +67,81 @@ pub enum DataLayout {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse VDS mappings from global heap object data.
|
/// Parse VDS mappings from global-heap object data.
|
||||||
///
|
///
|
||||||
/// The global heap object for a VDS layout contains a serialized list of
|
/// The global-heap block holding a VDS mapping list is laid out as
|
||||||
/// source mappings. Each mapping has:
|
/// (reverse-engineered and validated against HDF5 2.0):
|
||||||
/// - Virtual selection (serialized dataspace selection, variable length)
|
|
||||||
/// - Source file name (null-terminated string)
|
|
||||||
/// - Source dataset name (null-terminated string)
|
|
||||||
/// - Source selection (serialized dataspace selection, variable length)
|
|
||||||
///
|
///
|
||||||
/// The overall format starts with:
|
/// ```text
|
||||||
/// - version (4 bytes LE) — currently 0
|
/// version(1) · nused(length_size, LE) · entry[nused] · checksum(4)
|
||||||
/// - entry count (not explicitly stored; parse until data exhausted)
|
/// ```
|
||||||
///
|
///
|
||||||
/// This is a best-effort parser that handles common VDS files. The exact
|
/// Each entry is:
|
||||||
/// binary format is not fully specified publicly and may vary by HDF5 version.
|
/// - source file name — a null-terminated string in **block version 0**; in
|
||||||
pub fn parse_vds_mappings(heap_data: &[u8]) -> Result<Vec<VdsMapping>, FormatError> {
|
/// **block version 1** a same-file reference is encoded as a single `0x04`
|
||||||
if heap_data.len() < 4 {
|
/// marker byte (the source file is the virtual file itself) in place of the
|
||||||
|
/// name;
|
||||||
|
/// - source dataset name (null-terminated string);
|
||||||
|
/// - source selection (serialized `H5S` dataspace selection — self-describing
|
||||||
|
/// in length);
|
||||||
|
/// - virtual selection (serialized `H5S` dataspace selection).
|
||||||
|
///
|
||||||
|
/// The selections are decoded with [`crate::selection::Selection`] purely to
|
||||||
|
/// learn their byte length so the entry list can be walked; the raw selection
|
||||||
|
/// bytes are retained on each [`VdsMapping`] for the reader to interpret.
|
||||||
|
pub fn parse_vds_mappings(
|
||||||
|
heap_data: &[u8],
|
||||||
|
length_size: u8,
|
||||||
|
) -> Result<Vec<VdsMapping>, FormatError> {
|
||||||
|
use crate::selection::Selection;
|
||||||
|
|
||||||
|
let ls = length_size as usize;
|
||||||
|
if heap_data.len() < 1 + ls {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
// VDS global heap object starts with version(4)
|
let version = heap_data[0];
|
||||||
let _version = u32::from_le_bytes([heap_data[0], heap_data[1], heap_data[2], heap_data[3]]);
|
let mut pos = 1;
|
||||||
let mut pos = 4;
|
let nused = read_length(heap_data, pos, length_size)?;
|
||||||
|
pos += ls;
|
||||||
|
|
||||||
|
// `nused` is untrusted; don't pre-allocate from it. Each entry consumes at
|
||||||
|
// least a few bytes, so the loop is naturally bounded by the heap data and
|
||||||
|
// a bogus `nused` simply errors out on the first short read.
|
||||||
let mut mappings = Vec::new();
|
let mut mappings = Vec::new();
|
||||||
|
// Reads one self-describing selection at `pos`, returning its raw bytes and
|
||||||
|
// advancing past it — bounds-checked so a corrupt selection can't overrun.
|
||||||
|
let read_selection = |heap_data: &[u8], pos: &mut usize| -> Result<Vec<u8>, FormatError> {
|
||||||
|
let rest = heap_data.get(*pos..).ok_or(FormatError::UnexpectedEof {
|
||||||
|
expected: *pos,
|
||||||
|
available: heap_data.len(),
|
||||||
|
})?;
|
||||||
|
let (_, len) = Selection::decode_serialized(rest)?;
|
||||||
|
let bytes = rest
|
||||||
|
.get(..len)
|
||||||
|
.ok_or(FormatError::UnexpectedEof {
|
||||||
|
expected: pos.saturating_add(len),
|
||||||
|
available: heap_data.len(),
|
||||||
|
})?
|
||||||
|
.to_vec();
|
||||||
|
*pos += len;
|
||||||
|
Ok(bytes)
|
||||||
|
};
|
||||||
|
|
||||||
while pos < heap_data.len() {
|
for _ in 0..nused {
|
||||||
// Each entry: virtual_selection_size(4) + virtual_selection(N) +
|
// Source file name (with the version-1 same-file marker handled).
|
||||||
// source_file_name(null-term) + source_dataset_name(null-term) +
|
let source_file = if version >= 1 && heap_data.get(pos) == Some(&0x04) {
|
||||||
// source_selection_size(4) + source_selection(N)
|
pos += 1;
|
||||||
if pos + 4 > heap_data.len() {
|
String::from(".")
|
||||||
break;
|
} else {
|
||||||
}
|
read_null_terminated_string(heap_data, &mut pos)?
|
||||||
|
};
|
||||||
|
|
||||||
// Virtual selection
|
// Source dataset name.
|
||||||
let vsel_size = u32::from_le_bytes([
|
|
||||||
heap_data[pos],
|
|
||||||
heap_data[pos + 1],
|
|
||||||
heap_data[pos + 2],
|
|
||||||
heap_data[pos + 3],
|
|
||||||
]) as usize;
|
|
||||||
pos += 4;
|
|
||||||
if pos + vsel_size > heap_data.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let virtual_selection = heap_data[pos..pos + vsel_size].to_vec();
|
|
||||||
pos += vsel_size;
|
|
||||||
|
|
||||||
// Source file name (null-terminated)
|
|
||||||
let source_file = read_null_terminated_string(heap_data, &mut pos)?;
|
|
||||||
|
|
||||||
// Source dataset name (null-terminated)
|
|
||||||
let source_dataset = read_null_terminated_string(heap_data, &mut pos)?;
|
let source_dataset = read_null_terminated_string(heap_data, &mut pos)?;
|
||||||
|
|
||||||
// Source selection
|
// Source selection, then virtual selection (both self-describing length).
|
||||||
if pos + 4 > heap_data.len() {
|
let source_selection = read_selection(heap_data, &mut pos)?;
|
||||||
break;
|
let virtual_selection = read_selection(heap_data, &mut pos)?;
|
||||||
}
|
|
||||||
let ssel_size = u32::from_le_bytes([
|
|
||||||
heap_data[pos],
|
|
||||||
heap_data[pos + 1],
|
|
||||||
heap_data[pos + 2],
|
|
||||||
heap_data[pos + 3],
|
|
||||||
]) as usize;
|
|
||||||
pos += 4;
|
|
||||||
if pos + ssel_size > heap_data.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let source_selection = heap_data[pos..pos + ssel_size].to_vec();
|
|
||||||
pos += ssel_size;
|
|
||||||
|
|
||||||
mappings.push(VdsMapping {
|
mappings.push(VdsMapping {
|
||||||
source_file,
|
source_file,
|
||||||
@@ -235,7 +242,7 @@ impl DataLayout {
|
|||||||
index: *global_heap_index as u16,
|
index: *global_heap_index as u16,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
*mappings = parse_vds_mappings(&obj.data)?;
|
*mappings = parse_vds_mappings(&obj.data, length_size)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -250,7 +257,9 @@ impl DataLayout {
|
|||||||
|
|
||||||
match version {
|
match version {
|
||||||
3 => Self::parse_v3(data, layout_class, offset_size, length_size),
|
3 => Self::parse_v3(data, layout_class, offset_size, length_size),
|
||||||
4 => Self::parse_v4(data, layout_class, offset_size, length_size),
|
// v5 (emitted by HDF5 1.14+/2.0 with `libver=latest`) uses the same
|
||||||
|
// message structure as v4 — only the version number was bumped.
|
||||||
|
4 | 5 => Self::parse_v4(data, layout_class, offset_size, length_size),
|
||||||
_ => Err(FormatError::InvalidLayoutVersion(version)),
|
_ => Err(FormatError::InvalidLayoutVersion(version)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -626,6 +635,30 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v5_chunked_from_hdf5_2_0() {
|
||||||
|
// Real data layout message from h5py 3.16 / HDF5 2.0 (`libver=latest`)
|
||||||
|
// for a gzip-compressed 1-D chunked dataset. Version 5 uses the same
|
||||||
|
// structure as v4 (here: chunked, Fixed Array index). Regression guard
|
||||||
|
// for reading modern-format chunked datasets.
|
||||||
|
let bytes: [u8; 17] = [
|
||||||
|
0x05, 0x02, 0x00, 0x02, 0x01, 0x0a, 0x08, 0x03, 0x0a, 0xef, 0x05, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00,
|
||||||
|
];
|
||||||
|
let layout = DataLayout::parse(&bytes, 8, 8).unwrap();
|
||||||
|
match layout {
|
||||||
|
DataLayout::Chunked {
|
||||||
|
chunk_dimensions,
|
||||||
|
chunk_index_type,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(chunk_dimensions, vec![10, 8]);
|
||||||
|
assert_eq!(chunk_index_type, Some(3)); // Fixed Array
|
||||||
|
}
|
||||||
|
other => panic!("expected Chunked, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn v4_chunked_single_chunk_no_filters() {
|
fn v4_chunked_single_chunk_no_filters() {
|
||||||
let mut buf = vec![4u8, 2]; // version=4, class=2
|
let mut buf = vec![4u8, 2]; // version=4, class=2
|
||||||
@@ -678,9 +711,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_version() {
|
fn invalid_version() {
|
||||||
let buf = vec![5u8, 0, 0, 0];
|
// v3-v5 are supported; v6 is not a real layout message version.
|
||||||
|
let buf = vec![6u8, 0, 0, 0];
|
||||||
let err = DataLayout::parse(&buf, 8, 8).unwrap_err();
|
let err = DataLayout::parse(&buf, 8, 8).unwrap_err();
|
||||||
assert_eq!(err, FormatError::InvalidLayoutVersion(5));
|
assert_eq!(err, FormatError::InvalidLayoutVersion(6));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -747,32 +781,85 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_vds_mappings_basic() {
|
fn parse_vds_mappings_same_file_v1() {
|
||||||
// Build a simple VDS mapping blob
|
// The exact global-heap block written by HDF5 2.0 for a same-file VDS
|
||||||
let mut blob = Vec::new();
|
// with two sources: src_a -> virtual[0:4], src_b -> virtual[4:8].
|
||||||
blob.extend_from_slice(&0u32.to_le_bytes()); // version=0
|
let blob = [
|
||||||
|
0x01u8, // block version 1
|
||||||
|
0x02, 0, 0, 0, 0, 0, 0, 0, // nused = 2 (length_size = 8)
|
||||||
|
// entry 0
|
||||||
|
0x04, // same-file marker (replaces file name)
|
||||||
|
0x73, 0x72, 0x63, 0x5f, 0x61, 0x00, // "src_a\0"
|
||||||
|
0x03, 0, 0, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // source sel = ALL
|
||||||
|
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x01, 0, 0, 0, // virtual sel: HYPER v3
|
||||||
|
0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x04, 0x00, // start0 stride1 count1 block4
|
||||||
|
// entry 1
|
||||||
|
0x04, 0x73, 0x72, 0x63, 0x5f, 0x62, 0x00, // "src_b\0"
|
||||||
|
0x03, 0, 0, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // source sel = ALL
|
||||||
|
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x01, 0, 0, 0, // virtual sel: HYPER v3
|
||||||
|
0x04, 0x00, 0x01, 0x00, 0x01, 0x00, 0x04, 0x00, // start4 stride1 count1 block4
|
||||||
|
0x68, 0xf0, 0x3e, 0xe4, // checksum (ignored)
|
||||||
|
];
|
||||||
|
let mappings = parse_vds_mappings(&blob, 8).unwrap();
|
||||||
|
assert_eq!(mappings.len(), 2);
|
||||||
|
assert_eq!(mappings[0].source_file, ".");
|
||||||
|
assert_eq!(mappings[0].source_dataset, "src_a");
|
||||||
|
assert_eq!(mappings[1].source_file, ".");
|
||||||
|
assert_eq!(mappings[1].source_dataset, "src_b");
|
||||||
|
|
||||||
// Virtual selection (8 bytes of dummy data)
|
// Virtual selections decode to [0:4] and [4:8].
|
||||||
let vsel = vec![1, 2, 3, 4, 5, 6, 7, 8];
|
use crate::selection::Selection;
|
||||||
blob.extend_from_slice(&(vsel.len() as u32).to_le_bytes());
|
let (v0, _) = Selection::decode_serialized(&mappings[0].virtual_selection).unwrap();
|
||||||
blob.extend_from_slice(&vsel);
|
let (v1, _) = Selection::decode_serialized(&mappings[1].virtual_selection).unwrap();
|
||||||
|
assert_eq!(v0.iter_linear_1d(8).unwrap(), vec![0, 1, 2, 3]);
|
||||||
|
assert_eq!(v1.iter_linear_1d(8).unwrap(), vec![4, 5, 6, 7]);
|
||||||
|
}
|
||||||
|
|
||||||
// Source file name
|
#[test]
|
||||||
blob.extend_from_slice(b"source.h5\0");
|
fn parse_vds_mappings_external_v0() {
|
||||||
|
// Block version 0 with an explicit (external) source file name.
|
||||||
// Source dataset name
|
let blob = [
|
||||||
blob.extend_from_slice(b"/data\0");
|
0x00u8, // block version 0
|
||||||
|
0x01, 0, 0, 0, 0, 0, 0, 0, // nused = 1
|
||||||
// Source selection (4 bytes)
|
0x73, 0x72, 0x63, 0x5f, 0x65, 0x78, 0x74, 0x2e, 0x68, 0x35,
|
||||||
let ssel = vec![10, 20, 30, 40];
|
0x00, // "src_ext.h5\0"
|
||||||
blob.extend_from_slice(&(ssel.len() as u32).to_le_bytes());
|
0x64, 0x61, 0x74, 0x61, 0x00, // "data\0"
|
||||||
blob.extend_from_slice(&ssel);
|
0x03, 0, 0, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // source sel = ALL
|
||||||
|
0x03, 0, 0, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // virtual sel = ALL
|
||||||
let mappings = parse_vds_mappings(&blob).unwrap();
|
];
|
||||||
|
let mappings = parse_vds_mappings(&blob, 8).unwrap();
|
||||||
assert_eq!(mappings.len(), 1);
|
assert_eq!(mappings.len(), 1);
|
||||||
assert_eq!(mappings[0].source_file, "source.h5");
|
assert_eq!(mappings[0].source_file, "src_ext.h5");
|
||||||
assert_eq!(mappings[0].source_dataset, "/data");
|
assert_eq!(mappings[0].source_dataset, "data");
|
||||||
assert_eq!(mappings[0].virtual_selection, vsel);
|
}
|
||||||
assert_eq!(mappings[0].source_selection, ssel);
|
|
||||||
|
#[test]
|
||||||
|
fn parse_vds_mappings_huge_nused_does_not_oom_or_panic() {
|
||||||
|
// nused = u64::MAX with no entry data: must error, not pre-allocate or
|
||||||
|
// overrun.
|
||||||
|
let mut blob = vec![0x01u8];
|
||||||
|
blob.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||||
|
assert!(parse_vds_mappings(&blob, 8).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_vds_mappings_truncated_selection_does_not_overrun() {
|
||||||
|
// One entry whose source selection (ALL) is truncated to 8 of 16 bytes.
|
||||||
|
let blob = [
|
||||||
|
0x01u8, // version 1
|
||||||
|
0x01, 0, 0, 0, 0, 0, 0, 0, // nused = 1
|
||||||
|
0x04, // same-file marker
|
||||||
|
0x78, 0x00, // "x\0"
|
||||||
|
0x03, 0, 0, 0, 0x01, 0, 0, 0, // ALL header, truncated (8 of 16 bytes)
|
||||||
|
];
|
||||||
|
assert!(parse_vds_mappings(&blob, 8).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_vds_mappings_empty_is_ok_empty() {
|
||||||
|
assert!(parse_vds_mappings(&[], 8).unwrap().is_empty());
|
||||||
|
// Header present, nused = 0.
|
||||||
|
let blob = [0x01u8, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||||
|
assert!(parse_vds_mappings(&blob, 8).unwrap().is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +73,16 @@ pub fn read_raw_data(
|
|||||||
read_raw_data_full(file_data, layout, dataspace, datatype, None, 8, 8)
|
read_raw_data_full(file_data, layout, dataspace, datatype, None, 8, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves a Virtual Dataset source **file name** (as stored in the mapping,
|
||||||
|
/// e.g. `"ext_src.h5"`) to that file's raw bytes.
|
||||||
|
///
|
||||||
|
/// The pure-byte read API has no filesystem of its own, so external-file VDS
|
||||||
|
/// sources are read through a caller-supplied resolver. The std file API wires
|
||||||
|
/// one that reads relative to the virtual file's directory; callers can supply
|
||||||
|
/// their own (e.g. an in-memory map) in `no_std` builds. Returning `None` means
|
||||||
|
/// the source file is unavailable and the mapping is skipped.
|
||||||
|
pub type VdsSourceResolver<'a> = dyn Fn(&str) -> Option<Vec<u8>> + 'a;
|
||||||
|
|
||||||
/// Read raw bytes with full parameters including filter pipeline and sizes.
|
/// Read raw bytes with full parameters including filter pipeline and sizes.
|
||||||
pub fn read_raw_data_full(
|
pub fn read_raw_data_full(
|
||||||
file_data: &[u8],
|
file_data: &[u8],
|
||||||
@@ -82,6 +92,54 @@ pub fn read_raw_data_full(
|
|||||||
pipeline: Option<&FilterPipeline>,
|
pipeline: Option<&FilterPipeline>,
|
||||||
offset_size: u8,
|
offset_size: u8,
|
||||||
length_size: u8,
|
length_size: u8,
|
||||||
|
) -> Result<Vec<u8>, FormatError> {
|
||||||
|
read_raw_data_full_impl(
|
||||||
|
file_data,
|
||||||
|
layout,
|
||||||
|
dataspace,
|
||||||
|
datatype,
|
||||||
|
pipeline,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`read_raw_data_full`], but with a resolver for external-file Virtual
|
||||||
|
/// Dataset sources. For non-virtual layouts the resolver is ignored.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn read_raw_data_full_with_resolver(
|
||||||
|
file_data: &[u8],
|
||||||
|
layout: &DataLayout,
|
||||||
|
dataspace: &Dataspace,
|
||||||
|
datatype: &Datatype,
|
||||||
|
pipeline: Option<&FilterPipeline>,
|
||||||
|
offset_size: u8,
|
||||||
|
length_size: u8,
|
||||||
|
resolver: Option<&VdsSourceResolver>,
|
||||||
|
) -> Result<Vec<u8>, FormatError> {
|
||||||
|
read_raw_data_full_impl(
|
||||||
|
file_data,
|
||||||
|
layout,
|
||||||
|
dataspace,
|
||||||
|
datatype,
|
||||||
|
pipeline,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
resolver,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn read_raw_data_full_impl(
|
||||||
|
file_data: &[u8],
|
||||||
|
layout: &DataLayout,
|
||||||
|
dataspace: &Dataspace,
|
||||||
|
datatype: &Datatype,
|
||||||
|
pipeline: Option<&FilterPipeline>,
|
||||||
|
offset_size: u8,
|
||||||
|
length_size: u8,
|
||||||
|
resolver: Option<&VdsSourceResolver>,
|
||||||
) -> Result<Vec<u8>, FormatError> {
|
) -> Result<Vec<u8>, FormatError> {
|
||||||
let num_elements = dataspace.num_elements() as usize;
|
let num_elements = dataspace.num_elements() as usize;
|
||||||
let elem_size = datatype.type_size() as usize;
|
let elem_size = datatype.type_size() as usize;
|
||||||
@@ -128,7 +186,20 @@ pub fn read_raw_data_full(
|
|||||||
offset_size,
|
offset_size,
|
||||||
length_size,
|
length_size,
|
||||||
),
|
),
|
||||||
DataLayout::Virtual { .. } => Err(FormatError::UnsupportedVersion(0)),
|
DataLayout::Virtual {
|
||||||
|
global_heap_address,
|
||||||
|
global_heap_index,
|
||||||
|
..
|
||||||
|
} => read_virtual_data(
|
||||||
|
file_data,
|
||||||
|
*global_heap_address,
|
||||||
|
*global_heap_index,
|
||||||
|
dataspace,
|
||||||
|
datatype,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
resolver,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,10 +426,172 @@ pub fn read_raw_data_selection(
|
|||||||
)?;
|
)?;
|
||||||
extract_selection_from_buffer(&full_data, dims, elem_size, selection)
|
extract_selection_from_buffer(&full_data, dims, elem_size, selection)
|
||||||
}
|
}
|
||||||
DataLayout::Virtual { .. } => Err(FormatError::UnsupportedVersion(0)),
|
DataLayout::Virtual { .. } => {
|
||||||
|
// Assemble the full virtual dataset, then apply the read selection.
|
||||||
|
let full_data = read_raw_data_full(
|
||||||
|
file_data,
|
||||||
|
layout,
|
||||||
|
dataspace,
|
||||||
|
datatype,
|
||||||
|
pipeline,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
)?;
|
||||||
|
extract_selection_from_buffer(&full_data, dims, elem_size, selection)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Assemble a **Virtual Dataset (VDS)** from its source mappings.
|
||||||
|
///
|
||||||
|
/// Supports virtual datasets of any rank. Same-file sources are read directly;
|
||||||
|
/// **external-file** sources are read through the caller-supplied `resolver`,
|
||||||
|
/// which maps a stored source file name to that file's bytes. Each mapping's
|
||||||
|
/// selected source elements are scattered into the virtual buffer at the
|
||||||
|
/// positions given by the virtual selection (both enumerated in row-major
|
||||||
|
/// order, as HDF5 pairs them). Unmapped regions are left at the zero fill value.
|
||||||
|
///
|
||||||
|
/// A mapping whose external source file the resolver cannot supply (`None`) is
|
||||||
|
/// skipped, leaving its region at fill — matching HDF5's tolerance of missing
|
||||||
|
/// sources. An external source with no resolver at all is a hard error.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn read_virtual_data(
|
||||||
|
file_data: &[u8],
|
||||||
|
global_heap_address: Option<u64>,
|
||||||
|
global_heap_index: u32,
|
||||||
|
dataspace: &Dataspace,
|
||||||
|
datatype: &Datatype,
|
||||||
|
offset_size: u8,
|
||||||
|
length_size: u8,
|
||||||
|
resolver: Option<&VdsSourceResolver>,
|
||||||
|
) -> Result<Vec<u8>, FormatError> {
|
||||||
|
use crate::data_layout::parse_vds_mappings;
|
||||||
|
use crate::global_heap::GlobalHeapCollection;
|
||||||
|
use crate::selection::Selection;
|
||||||
|
|
||||||
|
let elem_size = datatype.type_size() as usize;
|
||||||
|
let total_elems = dataspace.num_elements() as usize;
|
||||||
|
let mut out = vec![0u8; total_elems.saturating_mul(elem_size)];
|
||||||
|
|
||||||
|
let virtual_dims = &dataspace.dimensions;
|
||||||
|
|
||||||
|
let addr = global_heap_address.ok_or_else(|| {
|
||||||
|
FormatError::ChunkedReadError("virtual dataset has no mapping global heap".into())
|
||||||
|
})?;
|
||||||
|
let coll = GlobalHeapCollection::parse(file_data, addr as usize, length_size)?;
|
||||||
|
let obj =
|
||||||
|
coll.get_object(global_heap_index as u16)
|
||||||
|
.ok_or(FormatError::GlobalHeapObjectNotFound {
|
||||||
|
collection_address: addr,
|
||||||
|
index: global_heap_index as u16,
|
||||||
|
})?;
|
||||||
|
let mappings = parse_vds_mappings(&obj.data, length_size)?;
|
||||||
|
|
||||||
|
for m in &mappings {
|
||||||
|
let same_file = m.source_file.is_empty() || m.source_file == ".";
|
||||||
|
|
||||||
|
// Resolve the bytes of the file holding this source dataset.
|
||||||
|
let external;
|
||||||
|
let src_file_data: &[u8] = if same_file {
|
||||||
|
file_data
|
||||||
|
} else {
|
||||||
|
let r = resolver.ok_or_else(|| {
|
||||||
|
FormatError::ChunkedReadError(
|
||||||
|
"external-file virtual dataset sources require a file resolver".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
match r(&m.source_file) {
|
||||||
|
Some(bytes) => {
|
||||||
|
external = bytes;
|
||||||
|
&external
|
||||||
|
}
|
||||||
|
// Source file unavailable: leave this region at fill value.
|
||||||
|
None => continue,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (vsel, _) = Selection::decode_serialized(&m.virtual_selection)?;
|
||||||
|
let (ssel, _) = Selection::decode_serialized(&m.source_selection)?;
|
||||||
|
|
||||||
|
let (src_raw, src_dims) =
|
||||||
|
read_named_dataset_raw(src_file_data, &m.source_dataset, offset_size, length_size)?;
|
||||||
|
|
||||||
|
let vidx = vsel.iter_linear(virtual_dims)?;
|
||||||
|
let sidx = ssel.iter_linear(&src_dims)?;
|
||||||
|
if vidx.len() != sidx.len() {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"virtual/source selection element counts differ".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (&v, &s) in vidx.iter().zip(sidx.iter()) {
|
||||||
|
let (vo, so) = (v as usize * elem_size, s as usize * elem_size);
|
||||||
|
if vo + elem_size > out.len() || so + elem_size > src_raw.len() {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"virtual dataset selection out of bounds".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out[vo..vo + elem_size].copy_from_slice(&src_raw[so..so + elem_size]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a named dataset's raw (decoded) bytes and its dimensions, navigating
|
||||||
|
/// from the superblock. Used to pull VDS source datasets out of the same file.
|
||||||
|
fn read_named_dataset_raw(
|
||||||
|
file_data: &[u8],
|
||||||
|
path: &str,
|
||||||
|
_offset_size: u8,
|
||||||
|
_length_size: u8,
|
||||||
|
) -> Result<(Vec<u8>, Vec<u64>), FormatError> {
|
||||||
|
use crate::filter_pipeline::FilterPipeline;
|
||||||
|
use crate::group_v2::resolve_path_any;
|
||||||
|
use crate::message_type::MessageType;
|
||||||
|
use crate::object_header::ObjectHeader;
|
||||||
|
use crate::signature::find_signature;
|
||||||
|
use crate::superblock::Superblock;
|
||||||
|
|
||||||
|
let sig = find_signature(file_data)?;
|
||||||
|
let sb = Superblock::parse(file_data, sig)?;
|
||||||
|
let addr = resolve_path_any(file_data, &sb, path)?;
|
||||||
|
let hdr = ObjectHeader::parse(file_data, addr as usize, sb.offset_size, sb.length_size)?;
|
||||||
|
|
||||||
|
let find = |t: MessageType| hdr.messages.iter().find(|m| m.msg_type == t);
|
||||||
|
let ds_msg = find(MessageType::Dataspace)
|
||||||
|
.ok_or_else(|| FormatError::ChunkedReadError("VDS source has no dataspace".into()))?;
|
||||||
|
let dataspace = Dataspace::parse(&ds_msg.data, sb.length_size)?;
|
||||||
|
let dt_msg = find(MessageType::Datatype)
|
||||||
|
.ok_or_else(|| FormatError::ChunkedReadError("VDS source has no datatype".into()))?;
|
||||||
|
let (datatype, _) = Datatype::parse(&dt_msg.data)?;
|
||||||
|
let dl_msg = find(MessageType::DataLayout)
|
||||||
|
.ok_or_else(|| FormatError::ChunkedReadError("VDS source has no data layout".into()))?;
|
||||||
|
let layout = DataLayout::parse(&dl_msg.data, sb.offset_size, sb.length_size)?;
|
||||||
|
// A virtual dataset whose source is itself another virtual dataset could
|
||||||
|
// form a cycle (A -> B -> A) and recurse into a stack overflow. Nested
|
||||||
|
// virtual sources are exotic and unsupported, so stop here cleanly.
|
||||||
|
if matches!(layout, DataLayout::Virtual { .. }) {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"virtual dataset source is itself virtual (unsupported)".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let pipeline = find(MessageType::FilterPipeline)
|
||||||
|
.map(|m| FilterPipeline::parse(&m.data))
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
let raw = read_raw_data_full(
|
||||||
|
file_data,
|
||||||
|
&layout,
|
||||||
|
&dataspace,
|
||||||
|
&datatype,
|
||||||
|
pipeline.as_ref(),
|
||||||
|
sb.offset_size,
|
||||||
|
sb.length_size,
|
||||||
|
)?;
|
||||||
|
Ok((raw, dataspace.dimensions.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract selected elements from a full dataset buffer.
|
/// Extract selected elements from a full dataset buffer.
|
||||||
fn extract_selection_from_buffer(
|
fn extract_selection_from_buffer(
|
||||||
full_data: &[u8],
|
full_data: &[u8],
|
||||||
@@ -618,6 +851,11 @@ fn get_size(dt: &Datatype) -> usize {
|
|||||||
|
|
||||||
/// Convert raw bytes to `f64` values.
|
/// Convert raw bytes to `f64` values.
|
||||||
pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatError> {
|
pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatError> {
|
||||||
|
// Array datatypes (e.g. an array-typed compound member) are read as a flat
|
||||||
|
// sequence of their base elements.
|
||||||
|
if let Datatype::Array { base_type, .. } = datatype {
|
||||||
|
return read_as_f64(raw, base_type);
|
||||||
|
}
|
||||||
ensure_numeric(datatype, "FloatingPoint or FixedPoint")?;
|
ensure_numeric(datatype, "FloatingPoint or FixedPoint")?;
|
||||||
let elem_size = get_size(datatype);
|
let elem_size = get_size(datatype);
|
||||||
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
|
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
|
||||||
@@ -671,19 +909,27 @@ fn convert_to_f64(
|
|||||||
Ok(v as f64)
|
Ok(v as f64)
|
||||||
}
|
}
|
||||||
8 => Ok(read_f64_bytes(bytes, order)),
|
8 => Ok(read_f64_bytes(bytes, order)),
|
||||||
|
2 => Ok(read_f16_bytes(bytes, order) as f64),
|
||||||
_ => Err(FormatError::DataSizeMismatch {
|
_ => Err(FormatError::DataSizeMismatch {
|
||||||
expected: 8,
|
expected: 8,
|
||||||
actual: *size as usize,
|
actual: *size as usize,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
Datatype::FixedPoint { size, signed, .. } => {
|
Datatype::FixedPoint {
|
||||||
if *signed {
|
size,
|
||||||
let v = read_signed_int(bytes, *size as usize, order);
|
signed,
|
||||||
Ok(v as f64)
|
bit_offset,
|
||||||
|
bit_precision,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let full = read_unsigned_int(bytes, *size as usize, order);
|
||||||
|
let (off, prec) = effective_bits(*size as usize, *bit_offset, *bit_precision);
|
||||||
|
let v = if *signed {
|
||||||
|
extract_signed(full, off, prec) as f64
|
||||||
} else {
|
} else {
|
||||||
let v = read_unsigned_int(bytes, *size as usize, order);
|
extract_unsigned(full, off, prec) as f64
|
||||||
Ok(v as f64)
|
};
|
||||||
}
|
Ok(v)
|
||||||
}
|
}
|
||||||
_ => Err(FormatError::TypeMismatch {
|
_ => Err(FormatError::TypeMismatch {
|
||||||
expected: "numeric",
|
expected: "numeric",
|
||||||
@@ -694,6 +940,9 @@ fn convert_to_f64(
|
|||||||
|
|
||||||
/// Convert raw bytes to `i64` values.
|
/// Convert raw bytes to `i64` values.
|
||||||
pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatError> {
|
pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatError> {
|
||||||
|
if let Datatype::Array { base_type, .. } = datatype {
|
||||||
|
return read_as_i64(raw, base_type);
|
||||||
|
}
|
||||||
ensure_numeric(datatype, "FixedPoint (signed)")?;
|
ensure_numeric(datatype, "FixedPoint (signed)")?;
|
||||||
let elem_size = get_size(datatype);
|
let elem_size = get_size(datatype);
|
||||||
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
|
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
|
||||||
@@ -707,6 +956,7 @@ pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatEr
|
|||||||
// Fast path: native LE i64 — single bulk memcpy
|
// Fast path: native LE i64 — single bulk memcpy
|
||||||
#[cfg(target_endian = "little")]
|
#[cfg(target_endian = "little")]
|
||||||
if elem_size == 8
|
if elem_size == 8
|
||||||
|
&& is_full_width(datatype)
|
||||||
&& matches!(
|
&& matches!(
|
||||||
datatype,
|
datatype,
|
||||||
Datatype::FixedPoint {
|
Datatype::FixedPoint {
|
||||||
@@ -725,17 +975,21 @@ pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatEr
|
|||||||
}
|
}
|
||||||
|
|
||||||
let order = get_byte_order(datatype);
|
let order = get_byte_order(datatype);
|
||||||
|
let (off, prec) = fixed_bits(datatype);
|
||||||
let mut result = Vec::with_capacity(count);
|
let mut result = Vec::with_capacity(count);
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let chunk = &raw[i * elem_size..(i + 1) * elem_size];
|
let chunk = &raw[i * elem_size..(i + 1) * elem_size];
|
||||||
let v = read_signed_int(chunk, elem_size, &order);
|
let full = read_unsigned_int(chunk, elem_size, &order);
|
||||||
result.push(v);
|
result.push(extract_signed(full, off, prec));
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert raw bytes to `u64` values.
|
/// Convert raw bytes to `u64` values.
|
||||||
pub fn read_as_u64(raw: &[u8], datatype: &Datatype) -> Result<Vec<u64>, FormatError> {
|
pub fn read_as_u64(raw: &[u8], datatype: &Datatype) -> Result<Vec<u64>, FormatError> {
|
||||||
|
if let Datatype::Array { base_type, .. } = datatype {
|
||||||
|
return read_as_u64(raw, base_type);
|
||||||
|
}
|
||||||
ensure_numeric(datatype, "FixedPoint (unsigned)")?;
|
ensure_numeric(datatype, "FixedPoint (unsigned)")?;
|
||||||
let elem_size = get_size(datatype);
|
let elem_size = get_size(datatype);
|
||||||
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
|
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
|
||||||
@@ -746,17 +1000,21 @@ pub fn read_as_u64(raw: &[u8], datatype: &Datatype) -> Result<Vec<u64>, FormatEr
|
|||||||
}
|
}
|
||||||
let count = raw.len() / elem_size;
|
let count = raw.len() / elem_size;
|
||||||
let order = get_byte_order(datatype);
|
let order = get_byte_order(datatype);
|
||||||
|
let (off, prec) = fixed_bits(datatype);
|
||||||
let mut result = Vec::with_capacity(count);
|
let mut result = Vec::with_capacity(count);
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let chunk = &raw[i * elem_size..(i + 1) * elem_size];
|
let chunk = &raw[i * elem_size..(i + 1) * elem_size];
|
||||||
let v = read_unsigned_int(chunk, elem_size, &order);
|
let full = read_unsigned_int(chunk, elem_size, &order);
|
||||||
result.push(v);
|
result.push(extract_unsigned(full, off, prec));
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert raw bytes to `f32` values.
|
/// Convert raw bytes to `f32` values.
|
||||||
pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatError> {
|
pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatError> {
|
||||||
|
if let Datatype::Array { base_type, .. } = datatype {
|
||||||
|
return read_as_f32(raw, base_type);
|
||||||
|
}
|
||||||
ensure_numeric(datatype, "FloatingPoint")?;
|
ensure_numeric(datatype, "FloatingPoint")?;
|
||||||
let elem_size = get_size(datatype);
|
let elem_size = get_size(datatype);
|
||||||
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
|
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
|
||||||
@@ -796,17 +1054,30 @@ pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatEr
|
|||||||
Datatype::FloatingPoint { size: 8, .. } => {
|
Datatype::FloatingPoint { size: 8, .. } => {
|
||||||
result.push(read_f64_bytes(chunk, &order) as f32);
|
result.push(read_f64_bytes(chunk, &order) as f32);
|
||||||
}
|
}
|
||||||
|
Datatype::FloatingPoint { size: 2, .. } => {
|
||||||
|
result.push(read_f16_bytes(chunk, &order));
|
||||||
|
}
|
||||||
Datatype::FixedPoint {
|
Datatype::FixedPoint {
|
||||||
signed: true, size, ..
|
signed: true,
|
||||||
|
size,
|
||||||
|
bit_offset,
|
||||||
|
bit_precision,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
result.push(read_signed_int(chunk, *size as usize, &order) as f32);
|
let full = read_unsigned_int(chunk, *size as usize, &order);
|
||||||
|
let (off, prec) = effective_bits(*size as usize, *bit_offset, *bit_precision);
|
||||||
|
result.push(extract_signed(full, off, prec) as f32);
|
||||||
}
|
}
|
||||||
Datatype::FixedPoint {
|
Datatype::FixedPoint {
|
||||||
signed: false,
|
signed: false,
|
||||||
size,
|
size,
|
||||||
|
bit_offset,
|
||||||
|
bit_precision,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
result.push(read_unsigned_int(chunk, *size as usize, &order) as f32);
|
let full = read_unsigned_int(chunk, *size as usize, &order);
|
||||||
|
let (off, prec) = effective_bits(*size as usize, *bit_offset, *bit_precision);
|
||||||
|
result.push(extract_unsigned(full, off, prec) as f32);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(FormatError::TypeMismatch {
|
return Err(FormatError::TypeMismatch {
|
||||||
@@ -821,6 +1092,9 @@ pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatEr
|
|||||||
|
|
||||||
/// Convert raw bytes to `i32` values.
|
/// Convert raw bytes to `i32` values.
|
||||||
pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatError> {
|
pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatError> {
|
||||||
|
if let Datatype::Array { base_type, .. } = datatype {
|
||||||
|
return read_as_i32(raw, base_type);
|
||||||
|
}
|
||||||
ensure_numeric(datatype, "FixedPoint")?;
|
ensure_numeric(datatype, "FixedPoint")?;
|
||||||
let elem_size = get_size(datatype);
|
let elem_size = get_size(datatype);
|
||||||
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
|
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
|
||||||
@@ -834,6 +1108,7 @@ pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatEr
|
|||||||
// Fast path: native LE i32 — single bulk memcpy
|
// Fast path: native LE i32 — single bulk memcpy
|
||||||
#[cfg(target_endian = "little")]
|
#[cfg(target_endian = "little")]
|
||||||
if elem_size == 4
|
if elem_size == 4
|
||||||
|
&& is_full_width(datatype)
|
||||||
&& matches!(
|
&& matches!(
|
||||||
datatype,
|
datatype,
|
||||||
Datatype::FixedPoint {
|
Datatype::FixedPoint {
|
||||||
@@ -851,11 +1126,12 @@ pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatEr
|
|||||||
}
|
}
|
||||||
|
|
||||||
let order = get_byte_order(datatype);
|
let order = get_byte_order(datatype);
|
||||||
|
let (off, prec) = fixed_bits(datatype);
|
||||||
let mut result = Vec::with_capacity(count);
|
let mut result = Vec::with_capacity(count);
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let chunk = &raw[i * elem_size..(i + 1) * elem_size];
|
let chunk = &raw[i * elem_size..(i + 1) * elem_size];
|
||||||
let v = read_signed_int(chunk, elem_size, &order);
|
let full = read_unsigned_int(chunk, elem_size, &order);
|
||||||
result.push(v as i32);
|
result.push(extract_signed(full, off, prec) as i32);
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -1232,6 +1508,53 @@ fn read_f64_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f64 {
|
|||||||
f64::from_le_bytes(buf)
|
f64::from_le_bytes(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decode an IEEE-754 half-precision (binary16) value to `f32`. Pure integer
|
||||||
|
/// bit manipulation (no_std-safe, no `powi`/`libm`).
|
||||||
|
fn read_f16_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f32 {
|
||||||
|
let mut buf = [0u8; 2];
|
||||||
|
let len = bytes.len().min(2);
|
||||||
|
match order {
|
||||||
|
DatatypeByteOrder::BigEndian => {
|
||||||
|
for i in 0..len {
|
||||||
|
buf[i] = bytes[len - 1 - i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => buf[..len].copy_from_slice(&bytes[..len]),
|
||||||
|
}
|
||||||
|
f16_bits_to_f32(u16::from_le_bytes(buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert the bit pattern of an IEEE-754 half (binary16) to an `f32`.
|
||||||
|
fn f16_bits_to_f32(h: u16) -> f32 {
|
||||||
|
let h = h as u32;
|
||||||
|
let sign = (h & 0x8000) << 16;
|
||||||
|
let exp = (h >> 10) & 0x1f;
|
||||||
|
let mant = h & 0x3ff;
|
||||||
|
let bits = if exp == 0 {
|
||||||
|
if mant == 0 {
|
||||||
|
sign // signed zero
|
||||||
|
} else {
|
||||||
|
// Subnormal: normalize into an f32 normal.
|
||||||
|
let mut e: i32 = -1;
|
||||||
|
let mut m = mant;
|
||||||
|
loop {
|
||||||
|
e += 1;
|
||||||
|
m <<= 1;
|
||||||
|
if m & 0x400 != 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let m = m & 0x3ff;
|
||||||
|
sign | (((127 - 15 - e) as u32) << 23) | (m << 13)
|
||||||
|
}
|
||||||
|
} else if exp == 0x1f {
|
||||||
|
sign | 0x7f80_0000 | (mant << 13) // inf / NaN
|
||||||
|
} else {
|
||||||
|
sign | ((exp + (127 - 15)) << 23) | (mant << 13)
|
||||||
|
};
|
||||||
|
f32::from_bits(bits)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_f32_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f32 {
|
fn read_f32_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f32 {
|
||||||
let mut buf = [0u8; 4];
|
let mut buf = [0u8; 4];
|
||||||
let len = bytes.len().min(4);
|
let len = bytes.len().min(4);
|
||||||
@@ -1248,6 +1571,68 @@ fn read_f32_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f32 {
|
|||||||
f32::from_le_bytes(buf)
|
f32::from_le_bytes(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Effective (bit offset, bit precision) for a fixed-point field, defaulting a
|
||||||
|
/// zero precision to the full storage width.
|
||||||
|
fn effective_bits(size: usize, bit_offset: u16, bit_precision: u16) -> (u32, u32) {
|
||||||
|
let prec = if bit_precision == 0 {
|
||||||
|
(size * 8) as u32
|
||||||
|
} else {
|
||||||
|
bit_precision as u32
|
||||||
|
};
|
||||||
|
(bit_offset as u32, prec)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(bit_offset, bit_precision)` for a fixed-point datatype, full width for
|
||||||
|
/// other types.
|
||||||
|
fn fixed_bits(datatype: &Datatype) -> (u32, u32) {
|
||||||
|
match datatype {
|
||||||
|
Datatype::FixedPoint {
|
||||||
|
size,
|
||||||
|
bit_offset,
|
||||||
|
bit_precision,
|
||||||
|
..
|
||||||
|
} => effective_bits(*size as usize, *bit_offset, *bit_precision),
|
||||||
|
_ => (0, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a datatype occupies its full storage width (bit offset 0, precision
|
||||||
|
/// == size·8), in which case the bulk-copy fast read paths apply. Non
|
||||||
|
/// fixed-point types are treated as full width.
|
||||||
|
fn is_full_width(datatype: &Datatype) -> bool {
|
||||||
|
match datatype {
|
||||||
|
Datatype::FixedPoint {
|
||||||
|
size,
|
||||||
|
bit_offset,
|
||||||
|
bit_precision,
|
||||||
|
..
|
||||||
|
} => *bit_offset == 0 && *bit_precision as u32 == *size * 8,
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the `precision`-bit field at `offset` from a full-width integer read
|
||||||
|
/// and sign-extend it. Full-width fields read as an ordinary signed integer;
|
||||||
|
/// reduced-precision fields sign-extend from the field's top bit (HDF5 stores
|
||||||
|
/// reduced-precision values zero-filled, so the sign lives in the precision
|
||||||
|
/// field, not the storage word).
|
||||||
|
fn extract_signed(full: u64, offset: u32, precision: u32) -> i64 {
|
||||||
|
if precision == 0 || precision >= 64 {
|
||||||
|
return full as i64;
|
||||||
|
}
|
||||||
|
let field = (full >> offset) & ((1u64 << precision) - 1);
|
||||||
|
let shift = 64 - precision;
|
||||||
|
((field << shift) as i64) >> shift
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the `precision`-bit field at `offset` from a full-width integer read.
|
||||||
|
fn extract_unsigned(full: u64, offset: u32, precision: u32) -> u64 {
|
||||||
|
if precision == 0 || precision >= 64 {
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
(full >> offset) & ((1u64 << precision) - 1)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_unsigned_int(bytes: &[u8], size: usize, order: &DatatypeByteOrder) -> u64 {
|
fn read_unsigned_int(bytes: &[u8], size: usize, order: &DatatypeByteOrder) -> u64 {
|
||||||
let buf = reorder_bytes(bytes, order);
|
let buf = reorder_bytes(bytes, order);
|
||||||
match size {
|
match size {
|
||||||
@@ -1266,22 +1651,6 @@ fn read_unsigned_int(bytes: &[u8], size: usize, order: &DatatypeByteOrder) -> u6
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_signed_int(bytes: &[u8], size: usize, order: &DatatypeByteOrder) -> i64 {
|
|
||||||
let buf = reorder_bytes(bytes, order);
|
|
||||||
match size {
|
|
||||||
1 => buf[0] as i8 as i64,
|
|
||||||
2 => i16::from_le_bytes([buf[0], buf[1]]) as i64,
|
|
||||||
4 => i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) as i64,
|
|
||||||
8 => i64::from_le_bytes(buf),
|
|
||||||
_ => {
|
|
||||||
let u = read_unsigned_int(bytes, size, order);
|
|
||||||
// Sign extend
|
|
||||||
let shift = 64 - (size * 8);
|
|
||||||
((u as i64) << shift) >> shift
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Type conversion cost analysis ---
|
// --- Type conversion cost analysis ---
|
||||||
|
|
||||||
/// Cost classification for type conversions.
|
/// Cost classification for type conversions.
|
||||||
@@ -1362,6 +1731,119 @@ mod tests {
|
|||||||
use crate::dataspace::{Dataspace, DataspaceType};
|
use crate::dataspace::{Dataspace, DataspaceType};
|
||||||
use crate::datatype::{CharacterSet, StringPadding};
|
use crate::datatype::{CharacterSet, StringPadding};
|
||||||
|
|
||||||
|
fn f16_datatype() -> Datatype {
|
||||||
|
Datatype::FloatingPoint {
|
||||||
|
size: 2,
|
||||||
|
byte_order: DatatypeByteOrder::LittleEndian,
|
||||||
|
bit_offset: 0,
|
||||||
|
bit_precision: 16,
|
||||||
|
exponent_location: 10,
|
||||||
|
exponent_size: 5,
|
||||||
|
mantissa_location: 0,
|
||||||
|
mantissa_size: 10,
|
||||||
|
exponent_bias: 15,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IEEE-754 half bit patterns for known values.
|
||||||
|
fn f16_bits(v: f32) -> u16 {
|
||||||
|
// Encode a few exact values used by the test.
|
||||||
|
match v {
|
||||||
|
x if x == 0.0 => 0x0000,
|
||||||
|
x if x == 1.0 => 0x3c00,
|
||||||
|
x if x == -2.0 => 0xc000,
|
||||||
|
x if x == 0.5 => 0x3800,
|
||||||
|
x if x == 65504.0 => 0x7bff, // f16 max
|
||||||
|
_ => panic!("unsupported test value {v}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_f16_as_f32_and_f64() {
|
||||||
|
let values = [0.0f32, 1.0, -2.0, 0.5, 65504.0];
|
||||||
|
let raw: Vec<u8> = values
|
||||||
|
.iter()
|
||||||
|
.flat_map(|&v| f16_bits(v).to_le_bytes())
|
||||||
|
.collect();
|
||||||
|
let dt = f16_datatype();
|
||||||
|
let got32 = read_as_f32(&raw, &dt).unwrap();
|
||||||
|
assert_eq!(got32, values);
|
||||||
|
let got64 = read_as_f64(&raw, &dt).unwrap();
|
||||||
|
let expect64: Vec<f64> = values.iter().map(|&v| v as f64).collect();
|
||||||
|
assert_eq!(got64, expect64);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reduced_int(signed: bool, precision: u16) -> Datatype {
|
||||||
|
Datatype::FixedPoint {
|
||||||
|
size: 4,
|
||||||
|
byte_order: DatatypeByteOrder::LittleEndian,
|
||||||
|
signed,
|
||||||
|
bit_offset: 0,
|
||||||
|
bit_precision: precision,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reduced_precision_signed_sign_extends() {
|
||||||
|
// 16-bit-precision signed values stored zero-filled (HDF5's canonical
|
||||||
|
// layout, e.g. after N-Bit): the reader must sign-extend from bit 15.
|
||||||
|
let dt = reduced_int(true, 16);
|
||||||
|
// [-1, 100, -50, -32768] as 0x0000ffff / 0x00000064 / 0x0000ffce / 0x00008000
|
||||||
|
let raw: Vec<u8> = vec![
|
||||||
|
0xff, 0xff, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xce, 0xff, 0x00, 0x00, 0x00, 0x80,
|
||||||
|
0x00, 0x00,
|
||||||
|
];
|
||||||
|
assert_eq!(read_as_i32(&raw, &dt).unwrap(), vec![-1, 100, -50, -32768]);
|
||||||
|
assert_eq!(read_as_i64(&raw, &dt).unwrap(), vec![-1, 100, -50, -32768]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reduced_precision_unsigned_masks() {
|
||||||
|
// 12-bit-precision unsigned: high bits must read as zero, not sign.
|
||||||
|
let dt = reduced_int(false, 12);
|
||||||
|
// [4095, 1, 2048] as 0x00000fff / 0x00000001 / 0x00000800
|
||||||
|
let raw: Vec<u8> = vec![
|
||||||
|
0xff, 0x0f, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00,
|
||||||
|
];
|
||||||
|
assert_eq!(read_as_u64(&raw, &dt).unwrap(), vec![4095, 1, 2048]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_width_signed_unchanged() {
|
||||||
|
// Regression: full-width 32-bit signed must be unaffected.
|
||||||
|
let dt = reduced_int(true, 32);
|
||||||
|
let raw: Vec<u8> = vec![0xff, 0xff, 0xff, 0xff, 0x2a, 0x00, 0x00, 0x00];
|
||||||
|
assert_eq!(read_as_i32(&raw, &dt).unwrap(), vec![-1, 42]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn array_datatype_reads_flat_base_elements() {
|
||||||
|
// An array-typed (e.g. compound member) datatype reads as a flat
|
||||||
|
// sequence of its base elements, applying base-type precision rules.
|
||||||
|
let arr = Datatype::Array {
|
||||||
|
base_type: Box::new(reduced_int(true, 16)),
|
||||||
|
dimensions: vec![2],
|
||||||
|
};
|
||||||
|
// [-1, 100, 1000, -32768] stored zero-filled at 16-bit precision.
|
||||||
|
let raw: Vec<u8> = vec![
|
||||||
|
0xff, 0xff, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xe8, 0x03, 0x00, 0x00, 0x00, 0x80,
|
||||||
|
0x00, 0x00,
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
read_as_i32(&raw, &arr).unwrap(),
|
||||||
|
vec![-1, 100, 1000, -32768]
|
||||||
|
);
|
||||||
|
// Nested array-of-array unwraps recursively.
|
||||||
|
let nested = Datatype::Array {
|
||||||
|
base_type: Box::new(arr),
|
||||||
|
dimensions: vec![2],
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
read_as_i32(&raw, &nested).unwrap(),
|
||||||
|
vec![-1, 100, 1000, -32768]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn make_f64_le_type() -> Datatype {
|
fn make_f64_le_type() -> Datatype {
|
||||||
Datatype::FloatingPoint {
|
Datatype::FloatingPoint {
|
||||||
size: 8,
|
size: 8,
|
||||||
|
|||||||
@@ -348,7 +348,10 @@ impl Datatype {
|
|||||||
let num_members = (bf0 as u16) | ((bf1 as u16) << 8);
|
let num_members = (bf0 as u16) | ((bf1 as u16) << 8);
|
||||||
let mut members = Vec::with_capacity(num_members as usize);
|
let mut members = Vec::with_capacity(num_members as usize);
|
||||||
|
|
||||||
if version == 3 || version == 4 {
|
if (3..=5).contains(&version) {
|
||||||
|
// v3, v4 and v5 share the compact member encoding (name,
|
||||||
|
// variable-width offset, member datatype). HDF5 1.14+/2.0
|
||||||
|
// with `libver=latest` emits v5 compound types.
|
||||||
let ob = offset_bytes_for_size(size);
|
let ob = offset_bytes_for_size(size);
|
||||||
for _ in 0..num_members {
|
for _ in 0..num_members {
|
||||||
let (name, name_len) = read_null_terminated_string(data, pos)?;
|
let (name, name_len) = read_null_terminated_string(data, pos)?;
|
||||||
@@ -500,7 +503,9 @@ impl Datatype {
|
|||||||
},
|
},
|
||||||
pos,
|
pos,
|
||||||
))
|
))
|
||||||
} else if version == 3 {
|
} else if (3..=5).contains(&version) {
|
||||||
|
// v3, v4 and v5 share the array encoding (ndims, dims, base
|
||||||
|
// type); HDF5 1.14+/2.0 with `libver=latest` emits v5.
|
||||||
ensure_len(data, pos, 1)?;
|
ensure_len(data, pos, 1)?;
|
||||||
let ndims = data[pos] as usize;
|
let ndims = data[pos] as usize;
|
||||||
pos += 1;
|
pos += 1;
|
||||||
@@ -1007,6 +1012,73 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compound_v5_from_hdf5_2_0() {
|
||||||
|
// Real datatype message bytes emitted by h5py 3.16 / HDF5 2.0 with
|
||||||
|
// `libver=latest` for a compound dtype [('x','f8'),('y','f8'),('id','i4')].
|
||||||
|
// The wrapper is datatype version 5; members reuse the v3 compact
|
||||||
|
// encoding. Regression guard for reading modern-format compound types.
|
||||||
|
let bytes: [u8; 70] = [
|
||||||
|
0x56, 0x03, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x11, 0x20, 0x3f,
|
||||||
|
0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x34, 0x0b, 0x00, 0x34, 0xff,
|
||||||
|
0x03, 0x00, 0x00, 0x79, 0x00, 0x08, 0x11, 0x20, 0x3f, 0x00, 0x08, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x40, 0x00, 0x34, 0x0b, 0x00, 0x34, 0xff, 0x03, 0x00, 0x00, 0x69, 0x64,
|
||||||
|
0x00, 0x10, 0x10, 0x08, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00,
|
||||||
|
];
|
||||||
|
let (dt, _) = Datatype::parse(&bytes).unwrap();
|
||||||
|
match dt {
|
||||||
|
Datatype::Compound { size, members } => {
|
||||||
|
assert_eq!(size, 20);
|
||||||
|
assert_eq!(members.len(), 3);
|
||||||
|
assert_eq!((members[0].name.as_str(), members[0].byte_offset), ("x", 0));
|
||||||
|
assert_eq!((members[1].name.as_str(), members[1].byte_offset), ("y", 8));
|
||||||
|
assert_eq!(
|
||||||
|
(members[2].name.as_str(), members[2].byte_offset),
|
||||||
|
("id", 16)
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
members[0].datatype,
|
||||||
|
Datatype::FloatingPoint { size: 8, .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
members[2].datatype,
|
||||||
|
Datatype::FixedPoint {
|
||||||
|
size: 4,
|
||||||
|
signed: true,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => panic!("expected Compound"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_array_v5_from_hdf5_2_0() {
|
||||||
|
// Real datatype message from h5py 3.16 / HDF5 2.0 (`libver=latest`) for
|
||||||
|
// an array dtype `('f8', (3,))`: datatype version 5, class 10, reusing
|
||||||
|
// the v3 array encoding (ndims, dims, base type).
|
||||||
|
let bytes: [u8; 33] = [
|
||||||
|
0x5a, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x11,
|
||||||
|
0x20, 0x3f, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x34, 0x0b, 0x00,
|
||||||
|
0x34, 0xff, 0x03, 0x00, 0x00,
|
||||||
|
];
|
||||||
|
let (dt, _) = Datatype::parse(&bytes).unwrap();
|
||||||
|
match dt {
|
||||||
|
Datatype::Array {
|
||||||
|
base_type,
|
||||||
|
dimensions,
|
||||||
|
} => {
|
||||||
|
assert_eq!(dimensions, vec![3]);
|
||||||
|
assert!(matches!(
|
||||||
|
*base_type,
|
||||||
|
Datatype::FloatingPoint { size: 8, .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
other => panic!("expected Array, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_reference_object() {
|
fn test_reference_object() {
|
||||||
let buf = build_dt_header(7, 1, [0, 0, 0], 8);
|
let buf = build_dt_header(7, 1, [0, 0, 0], 8);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
#[cfg(not(feature = "std"))]
|
#[cfg(not(feature = "std"))]
|
||||||
use alloc::{string::String, vec, vec::Vec};
|
use alloc::{format, string::String, vec, vec::Vec};
|
||||||
|
|
||||||
#[cfg(not(feature = "std"))]
|
#[cfg(not(feature = "std"))]
|
||||||
use alloc::collections::BTreeMap;
|
use alloc::collections::BTreeMap;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,8 @@ pub const FILTER_SCALEOFFSET: u16 = 6;
|
|||||||
pub const FILTER_LZ4: u16 = 32004;
|
pub const FILTER_LZ4: u16 = 32004;
|
||||||
/// Zstandard compression.
|
/// Zstandard compression.
|
||||||
pub const FILTER_ZSTD: u16 = 32015;
|
pub const FILTER_ZSTD: u16 = 32015;
|
||||||
|
/// Pcodec lossless numerical codec (clawhdf5 internal; not yet HDF5-registered).
|
||||||
|
pub const FILTER_PCODEC: u16 = 32023;
|
||||||
|
|
||||||
/// Description of a single filter in a pipeline.
|
/// Description of a single filter in a pipeline.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
|||||||
|
//! SZIP (libaec Adaptive Entropy Coding) decompression.
|
||||||
|
//!
|
||||||
|
//! Gated by the `szip` feature which links against the system libaec library.
|
||||||
|
|
||||||
|
#[cfg(not(feature = "std"))]
|
||||||
|
use alloc::vec::Vec;
|
||||||
|
|
||||||
|
use crate::error::FormatError;
|
||||||
|
|
||||||
|
/// Decompress SZIP-compressed data using libaec.
|
||||||
|
///
|
||||||
|
/// `cd` is the HDF5 SZIP filter client data (matches `H5Z_SZIP_PARM_*` indices):
|
||||||
|
/// cd[0] = options mask (`H5_SZIP_NN_OPTION_MASK = 0x20` enables NN preprocessing)
|
||||||
|
/// cd[1] = pixels per block (H5Z_SZIP_PARM_PPB; 8, 10, 16, or 32)
|
||||||
|
/// cd[2] = bits per sample (H5Z_SZIP_PARM_BPP; element bit width)
|
||||||
|
/// cd[3] = pixels per scan line (H5Z_SZIP_PARM_PPS; informational only)
|
||||||
|
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() < 3 {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"szip: missing client data".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let options = cd[0];
|
||||||
|
let pixels_per_block = cd[1];
|
||||||
|
let bits_per_sample = cd[2]; // H5Z_SZIP_PARM_BPP
|
||||||
|
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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if data.is_empty() {
|
||||||
|
return Err(FormatError::ChunkedReadError("szip: empty input".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map HDF5 option mask to libaec flags.
|
||||||
|
// HDF5 always stores SZIP data in MSB order, so AEC_DATA_MSB is unconditional.
|
||||||
|
// H5_SZIP_NN_OPTION_MASK (0x20): NN differential preprocessing.
|
||||||
|
let mut flags: u32 = libaec_sys::AEC_DATA_MSB;
|
||||||
|
if options & 0x20 != 0 {
|
||||||
|
flags |= libaec_sys::AEC_DATA_PREPROCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = vec![0u8; chunk_size];
|
||||||
|
let mut strm = libaec_sys::AecStream::zeroed();
|
||||||
|
strm.next_in = data.as_ptr();
|
||||||
|
strm.avail_in = data.len();
|
||||||
|
strm.next_out = out.as_mut_ptr();
|
||||||
|
strm.avail_out = chunk_size;
|
||||||
|
strm.bits_per_sample = bits_per_sample;
|
||||||
|
strm.block_size = pixels_per_block;
|
||||||
|
strm.rsi = 128; // HDF5 default: 128 blocks per reference sample interval
|
||||||
|
strm.flags = flags;
|
||||||
|
|
||||||
|
let result = unsafe { libaec_sys::aec_buffer_decode(&mut strm) };
|
||||||
|
if result != 0 {
|
||||||
|
return Err(FormatError::DecompressionError(format!(
|
||||||
|
"szip: libaec error {result}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let decoded_len = chunk_size - strm.avail_out;
|
||||||
|
out.truncate(decoded_len);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn szip_disabled_returns_unsupported() {
|
||||||
|
#[cfg(not(feature = "szip"))]
|
||||||
|
{
|
||||||
|
let result = szip_decompress(&[], &[0, 8, 8, 1024], 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(&[], &[0, 8, 8, 1024], 64);
|
||||||
|
assert!(result.is_err(), "empty buffer must not succeed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round-trip test: encode with libaec then decode through szip_decompress.
|
||||||
|
///
|
||||||
|
/// Uses 1024 samples (rsi=128 × block_size=8) so the block count is exact.
|
||||||
|
#[cfg(feature = "szip")]
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_u8_msb_no_nn() {
|
||||||
|
use libaec_sys::{AEC_DATA_MSB, AecStream};
|
||||||
|
|
||||||
|
let original: Vec<u8> = (0..1024u32).map(|i| (i % 256) as u8).collect();
|
||||||
|
|
||||||
|
// Encode with libaec directly (no NN, MSB — mirrors what HDF5 always writes).
|
||||||
|
let mut encoded = vec![0u8; original.len() * 2];
|
||||||
|
let mut enc = AecStream::zeroed();
|
||||||
|
enc.next_in = original.as_ptr();
|
||||||
|
enc.avail_in = original.len();
|
||||||
|
enc.next_out = encoded.as_mut_ptr();
|
||||||
|
enc.avail_out = encoded.len();
|
||||||
|
enc.bits_per_sample = 8;
|
||||||
|
enc.block_size = 8;
|
||||||
|
enc.rsi = 128;
|
||||||
|
enc.flags = AEC_DATA_MSB;
|
||||||
|
let rc = unsafe { libaec_sys::aec_buffer_encode(&mut enc) };
|
||||||
|
assert_eq!(rc, 0, "aec_buffer_encode failed: {rc}");
|
||||||
|
let enc_len = encoded.len() - enc.avail_out;
|
||||||
|
encoded.truncate(enc_len);
|
||||||
|
|
||||||
|
// Decode through our public interface.
|
||||||
|
// cd[0]=0 (no NN bit 0x20), cd[1]=8 (ppb), cd[2]=8 (bpp), cd[3]=1024 (pps).
|
||||||
|
let cd = [0u32, 8, 8, 1024];
|
||||||
|
let decoded = szip_decompress(&encoded, &cd, original.len())
|
||||||
|
.expect("szip_decompress must succeed on valid libaec output");
|
||||||
|
assert_eq!(decoded, original, "round-trip must reproduce original data");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same round-trip but with NN preprocessing enabled (H5_SZIP_NN_OPTION_MASK = 0x20).
|
||||||
|
#[cfg(feature = "szip")]
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_u8_msb_with_nn() {
|
||||||
|
use libaec_sys::{AEC_DATA_MSB, AEC_DATA_PREPROCESS, AecStream};
|
||||||
|
|
||||||
|
let original: Vec<u8> = (0..1024u32).map(|i| (i % 256) as u8).collect();
|
||||||
|
|
||||||
|
let mut encoded = vec![0u8; original.len() * 2];
|
||||||
|
let mut enc = AecStream::zeroed();
|
||||||
|
enc.next_in = original.as_ptr();
|
||||||
|
enc.avail_in = original.len();
|
||||||
|
enc.next_out = encoded.as_mut_ptr();
|
||||||
|
enc.avail_out = encoded.len();
|
||||||
|
enc.bits_per_sample = 8;
|
||||||
|
enc.block_size = 8;
|
||||||
|
enc.rsi = 128;
|
||||||
|
enc.flags = AEC_DATA_MSB | AEC_DATA_PREPROCESS;
|
||||||
|
let rc = unsafe { libaec_sys::aec_buffer_encode(&mut enc) };
|
||||||
|
assert_eq!(rc, 0, "aec_buffer_encode with NN failed: {rc}");
|
||||||
|
let enc_len = encoded.len() - enc.avail_out;
|
||||||
|
encoded.truncate(enc_len);
|
||||||
|
|
||||||
|
// cd[0] = 0x20 (H5_SZIP_NN_OPTION_MASK) → decoder must set AEC_DATA_PREPROCESS.
|
||||||
|
let cd = [0x20u32, 8, 8, 1024];
|
||||||
|
let decoded = szip_decompress(&encoded, &cd, original.len())
|
||||||
|
.expect("szip_decompress with NN must succeed");
|
||||||
|
assert_eq!(
|
||||||
|
decoded, original,
|
||||||
|
"NN round-trip must reproduce original data"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,27 +140,36 @@ pub fn read_fixed_array_chunks(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip version(1) + client_id(1) + header_address(offset_size)
|
// Elements start immediately after the data block prefix.
|
||||||
let mut pos = db_header_size;
|
let elements_start = db_offset + db_header_size;
|
||||||
|
|
||||||
// Check if paged
|
let num_elements = header.num_elements as usize;
|
||||||
let page_size = 1u64 << header.max_nelmts_bits;
|
// A chunk index cannot describe more elements than the file has bytes (each
|
||||||
let is_paged = header.num_elements > page_size;
|
// element occupies at least `offset_size` bytes). Reject a corrupt count
|
||||||
|
// before it can drive a huge loop or overflow an offset computation.
|
||||||
if is_paged {
|
if num_elements > file_data.len() {
|
||||||
// For paged data blocks, we need to handle page bitmap + pages
|
|
||||||
// For now, implement non-paged path (covers most real-world cases)
|
|
||||||
return Err(FormatError::ChunkedReadError(
|
return Err(FormatError::ChunkedReadError(
|
||||||
"paged Fixed Array data blocks not yet supported".into(),
|
"Fixed Array element count exceeds file size".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-paged: elements stored directly
|
|
||||||
let num_elements = header.num_elements as usize;
|
|
||||||
let os = offset_size as usize;
|
let os = offset_size as usize;
|
||||||
|
// On-disk stride of one element. For non-filtered arrays the element is just
|
||||||
|
// the chunk address (== offset_size); for filtered arrays it is
|
||||||
|
// address + chunk_size + filter_mask (== header.element_size).
|
||||||
|
let elem_stride = (header.element_size as usize).max(os);
|
||||||
|
|
||||||
// Compute chunk offsets based on index
|
// Absolute file offset of element `idx` within a run starting at `base`,
|
||||||
// Chunks are stored in row-major order within the dataset space
|
// with overflow surfaced as a clean error rather than a panic/wrap.
|
||||||
|
let elem_at = |base: usize, idx: usize| -> Result<usize, FormatError> {
|
||||||
|
idx.checked_mul(elem_stride)
|
||||||
|
.and_then(|o| base.checked_add(o))
|
||||||
|
.ok_or(FormatError::ChunkedReadError(
|
||||||
|
"Fixed Array element offset overflow".into(),
|
||||||
|
))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute chunk offsets based on index.
|
||||||
|
// Chunks are stored in row-major order within the dataset space.
|
||||||
let mut num_chunks_per_dim = Vec::with_capacity(rank);
|
let mut num_chunks_per_dim = Vec::with_capacity(rank);
|
||||||
for d_idx in 0..rank {
|
for d_idx in 0..rank {
|
||||||
let ch_dim = chunk_dimensions[d_idx] as u64;
|
let ch_dim = chunk_dimensions[d_idx] as u64;
|
||||||
@@ -177,99 +186,152 @@ pub fn read_fixed_array_chunks(
|
|||||||
chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64;
|
chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64;
|
||||||
|
|
||||||
let mut chunks = Vec::new();
|
let mut chunks = Vec::new();
|
||||||
|
let push_element =
|
||||||
|
|i: usize, abs: usize, chunks: &mut Vec<ChunkInfo>| -> Result<(), FormatError> {
|
||||||
|
if let Some((address, chunk_size, filter_mask)) = parse_fa_element(
|
||||||
|
file_data,
|
||||||
|
abs,
|
||||||
|
header.client_id,
|
||||||
|
offset_size,
|
||||||
|
header.element_size,
|
||||||
|
chunk_byte_size,
|
||||||
|
)? {
|
||||||
|
let offsets = index_to_chunk_offsets(i, &num_chunks_per_dim, chunk_dimensions);
|
||||||
|
chunks.push(ChunkInfo {
|
||||||
|
chunk_size,
|
||||||
|
filter_mask,
|
||||||
|
offsets,
|
||||||
|
address,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
for i in 0..num_elements {
|
// A data block is paged when it holds more elements than fit in one page.
|
||||||
let abs_pos = db_offset
|
// `max_nelmts_bits` is an untrusted u8; a shift >= the pointer width would
|
||||||
.checked_add(pos)
|
// panic, so reject it (real page-size bits are tiny — 10 by default).
|
||||||
.ok_or(FormatError::UnexpectedEof {
|
if header.max_nelmts_bits as u32 >= usize::BITS {
|
||||||
expected: usize::MAX,
|
return Err(FormatError::ChunkedReadError(
|
||||||
available: file_data.len(),
|
"Fixed Array max_nelmts_bits too large".into(),
|
||||||
})?;
|
));
|
||||||
if abs_pos > file_data.len() {
|
}
|
||||||
return Err(FormatError::UnexpectedEof {
|
let page_nelmts = 1usize << header.max_nelmts_bits;
|
||||||
expected: abs_pos,
|
let is_paged = num_elements > page_nelmts;
|
||||||
available: file_data.len(),
|
|
||||||
});
|
if !is_paged {
|
||||||
|
// Non-paged: prefix, then `num_elements` elements packed directly,
|
||||||
|
// then a trailing checksum (which we don't validate).
|
||||||
|
for i in 0..num_elements {
|
||||||
|
push_element(i, elem_at(elements_start, i)?, &mut chunks)?;
|
||||||
}
|
}
|
||||||
let elem_data = &file_data[abs_pos..];
|
return Ok(chunks);
|
||||||
if header.client_id == 0 {
|
}
|
||||||
// Non-filtered: just address
|
|
||||||
if db_offset
|
|
||||||
.checked_add(pos)
|
|
||||||
.and_then(|p| p.checked_add(os))
|
|
||||||
.is_none_or(|end| end > file_data.len())
|
|
||||||
{
|
|
||||||
return Err(FormatError::UnexpectedEof {
|
|
||||||
expected: db_offset.saturating_add(pos).saturating_add(os),
|
|
||||||
available: file_data.len(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let address = read_offset(elem_data, 0, offset_size)?;
|
|
||||||
pos += os;
|
|
||||||
|
|
||||||
if is_undefined(file_data, db_offset + pos - os, offset_size) {
|
// Paged layout: prefix, then a page-init bitmap (one bit per page, MSB-first
|
||||||
continue; // unallocated chunk
|
// within each byte), then a 4-byte checksum, then the pages. Every page
|
||||||
}
|
// occupies a full slot of `page_nelmts` elements plus a 4-byte checksum;
|
||||||
|
// only the final page holds fewer elements. Uninitialized pages (bit clear)
|
||||||
|
// still occupy their slot on disk but are zero-filled, so the bitmap — not a
|
||||||
|
// 0xFF sentinel — is what marks a whole page as unallocated.
|
||||||
|
let stride_overflow =
|
||||||
|
|| FormatError::ChunkedReadError("Fixed Array page offset overflow".into());
|
||||||
|
let npages = num_elements.div_ceil(page_nelmts);
|
||||||
|
let bitmap_size = npages.div_ceil(8);
|
||||||
|
let bitmap_start = elements_start;
|
||||||
|
// prefix(db_header_size) + bitmap + checksum(4)
|
||||||
|
let pages_start = db_offset + db_header_size + bitmap_size + 4;
|
||||||
|
let page_stride = page_nelmts
|
||||||
|
.checked_mul(elem_stride)
|
||||||
|
.and_then(|x| x.checked_add(4))
|
||||||
|
.ok_or_else(stride_overflow)?;
|
||||||
|
|
||||||
let offsets = index_to_chunk_offsets(i, &num_chunks_per_dim, chunk_dimensions);
|
if bitmap_start + bitmap_size > file_data.len() {
|
||||||
chunks.push(ChunkInfo {
|
return Err(FormatError::UnexpectedEof {
|
||||||
chunk_size: chunk_byte_size as u32,
|
expected: bitmap_start + bitmap_size,
|
||||||
filter_mask: 0,
|
available: file_data.len(),
|
||||||
offsets,
|
});
|
||||||
address,
|
}
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Filtered: address(offset_size) + chunk_size(variable) + filter_mask(4)
|
|
||||||
let es = header.element_size as usize;
|
|
||||||
if es < os + 4 {
|
|
||||||
return Err(FormatError::ChunkedReadError(
|
|
||||||
"element_size too small for filtered element".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let chunk_size_bytes = es - os - 4;
|
|
||||||
let elem_total = os + chunk_size_bytes + 4;
|
|
||||||
if db_offset
|
|
||||||
.checked_add(pos)
|
|
||||||
.and_then(|p| p.checked_add(elem_total))
|
|
||||||
.is_none_or(|end| end > file_data.len())
|
|
||||||
{
|
|
||||||
return Err(FormatError::UnexpectedEof {
|
|
||||||
expected: db_offset.saturating_add(pos).saturating_add(elem_total),
|
|
||||||
available: file_data.len(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let address = read_offset(elem_data, 0, offset_size)?;
|
for p in 0..npages {
|
||||||
|
let page_first = p * page_nelmts; // < num_elements, cannot overflow
|
||||||
|
let page_count = core::cmp::min(page_nelmts, num_elements - page_first);
|
||||||
|
|
||||||
// Read chunk_size (variable length, little-endian)
|
// Check the page-init bit (MSB-first within each byte).
|
||||||
let chunk_size = read_variable_length(&elem_data[os..], chunk_size_bytes)?;
|
let bit_byte = file_data[bitmap_start + p / 8];
|
||||||
|
let bit_mask = 1u8 << (7 - (p % 8));
|
||||||
|
if bit_byte & bit_mask == 0 {
|
||||||
|
continue; // entire page unallocated
|
||||||
|
}
|
||||||
|
|
||||||
let fm_off = os + chunk_size_bytes;
|
let page_off = p
|
||||||
let filter_mask = u32::from_le_bytes([
|
.checked_mul(page_stride)
|
||||||
elem_data[fm_off],
|
.and_then(|o| pages_start.checked_add(o))
|
||||||
elem_data[fm_off + 1],
|
.ok_or_else(stride_overflow)?;
|
||||||
elem_data[fm_off + 2],
|
for e in 0..page_count {
|
||||||
elem_data[fm_off + 3],
|
push_element(page_first + e, elem_at(page_off, e)?, &mut chunks)?;
|
||||||
]);
|
|
||||||
pos += elem_total;
|
|
||||||
|
|
||||||
if is_undefined(file_data, db_offset + pos - elem_total, offset_size) {
|
|
||||||
continue; // unallocated chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
let offsets = index_to_chunk_offsets(i, &num_chunks_per_dim, chunk_dimensions);
|
|
||||||
chunks.push(ChunkInfo {
|
|
||||||
chunk_size: chunk_size as u32,
|
|
||||||
filter_mask,
|
|
||||||
offsets,
|
|
||||||
address,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(chunks)
|
Ok(chunks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a single Fixed Array element at absolute file offset `abs`.
|
||||||
|
///
|
||||||
|
/// Returns `Some((address, chunk_size, filter_mask))` for an allocated chunk, or
|
||||||
|
/// `None` if the element is undefined (an unallocated chunk, address all-`0xFF`).
|
||||||
|
fn parse_fa_element(
|
||||||
|
file_data: &[u8],
|
||||||
|
abs: usize,
|
||||||
|
client_id: u8,
|
||||||
|
offset_size: u8,
|
||||||
|
element_size: u8,
|
||||||
|
chunk_byte_size: u64,
|
||||||
|
) -> Result<Option<(u64, u32, u32)>, FormatError> {
|
||||||
|
let os = offset_size as usize;
|
||||||
|
if client_id == 0 {
|
||||||
|
// Non-filtered: element is just the chunk address.
|
||||||
|
if abs + os > file_data.len() {
|
||||||
|
return Err(FormatError::UnexpectedEof {
|
||||||
|
expected: abs + os,
|
||||||
|
available: file_data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if is_undefined(file_data, abs, offset_size) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let address = read_offset(file_data, abs, offset_size)?;
|
||||||
|
Ok(Some((address, chunk_byte_size as u32, 0)))
|
||||||
|
} else {
|
||||||
|
// Filtered: address(offset_size) + chunk_size(variable) + filter_mask(4)
|
||||||
|
let es = element_size as usize;
|
||||||
|
if es < os + 4 {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"element_size too small for filtered element".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let chunk_size_bytes = es - os - 4;
|
||||||
|
if abs + es > file_data.len() {
|
||||||
|
return Err(FormatError::UnexpectedEof {
|
||||||
|
expected: abs + es,
|
||||||
|
available: file_data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if is_undefined(file_data, abs, offset_size) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let address = read_offset(file_data, abs, offset_size)?;
|
||||||
|
let chunk_size = read_variable_length(&file_data[abs + os..], chunk_size_bytes)?;
|
||||||
|
let fm_off = abs + os + chunk_size_bytes;
|
||||||
|
let filter_mask = u32::from_le_bytes([
|
||||||
|
file_data[fm_off],
|
||||||
|
file_data[fm_off + 1],
|
||||||
|
file_data[fm_off + 2],
|
||||||
|
file_data[fm_off + 3],
|
||||||
|
]);
|
||||||
|
Ok(Some((address, chunk_size as u32, filter_mask)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a linear chunk index to N-dimensional chunk offsets in dataset space.
|
/// Convert a linear chunk index to N-dimensional chunk offsets in dataset space.
|
||||||
fn index_to_chunk_offsets(
|
fn index_to_chunk_offsets(
|
||||||
index: usize,
|
index: usize,
|
||||||
@@ -392,6 +454,41 @@ mod tests {
|
|||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Malformed headers must error, never panic (shift overflow, huge counts).
|
||||||
|
#[test]
|
||||||
|
fn read_rejects_oversized_max_nelmts_bits() {
|
||||||
|
let mut buf = vec![0u8; 512];
|
||||||
|
let fahd = 0x40usize;
|
||||||
|
buf[fahd..fahd + 4].copy_from_slice(b"FAHD");
|
||||||
|
buf[fahd + 4] = 0; // version
|
||||||
|
buf[fahd + 5] = 0; // client_id
|
||||||
|
buf[fahd + 6] = 8; // element_size
|
||||||
|
buf[fahd + 7] = 200; // max_nelmts_bits — absurd, would overflow a shift
|
||||||
|
buf[fahd + 8..fahd + 16].copy_from_slice(&3u64.to_le_bytes()); // num_elements
|
||||||
|
buf[fahd + 16..fahd + 24].copy_from_slice(&0x100u64.to_le_bytes());
|
||||||
|
// FADB so parsing reaches the paged check
|
||||||
|
let db = 0x100usize;
|
||||||
|
buf[db..db + 4].copy_from_slice(b"FADB");
|
||||||
|
let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap();
|
||||||
|
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
|
||||||
|
assert!(r.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_rejects_num_elements_larger_than_file() {
|
||||||
|
let mut buf = vec![0u8; 256];
|
||||||
|
let fahd = 0x40usize;
|
||||||
|
buf[fahd..fahd + 4].copy_from_slice(b"FAHD");
|
||||||
|
buf[fahd + 6] = 8;
|
||||||
|
buf[fahd + 7] = 10;
|
||||||
|
buf[fahd + 8..fahd + 16].copy_from_slice(&u64::MAX.to_le_bytes()); // absurd count
|
||||||
|
buf[fahd + 16..fahd + 24].copy_from_slice(&0x80u64.to_le_bytes());
|
||||||
|
buf[0x80..0x84].copy_from_slice(b"FADB");
|
||||||
|
let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap();
|
||||||
|
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
|
||||||
|
assert!(r.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_fixed_array_header_invalid_version() {
|
fn parse_fixed_array_header_invalid_version() {
|
||||||
let mut buf = vec![0u8; 256];
|
let mut buf = vec![0u8; 256];
|
||||||
@@ -535,4 +632,100 @@ mod tests {
|
|||||||
assert_eq!(chunks[2].address, 0x3000);
|
assert_eq!(chunks[2].address, 0x3000);
|
||||||
assert_eq!(chunks[2].chunk_size, 100);
|
assert_eq!(chunks[2].chunk_size, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a synthetic *paged* Fixed Array (non-filtered) and verify reading.
|
||||||
|
///
|
||||||
|
/// Layout reverse-engineered and confirmed against an HDF5 2.0 file:
|
||||||
|
/// after the FADB prefix comes a page-init bitmap (MSB-first within each
|
||||||
|
/// byte), a 4-byte checksum, then full-size page slots (`page_nelmts`
|
||||||
|
/// elements + a 4-byte checksum each), with only the last page shorter.
|
||||||
|
/// Uninitialized pages occupy their slot but are skipped via the bitmap.
|
||||||
|
#[test]
|
||||||
|
fn read_paged_non_filtered_chunks() {
|
||||||
|
let offset_size: u8 = 8;
|
||||||
|
let length_size: u8 = 8;
|
||||||
|
let os = offset_size as usize;
|
||||||
|
|
||||||
|
// page_nelmts = 1 << 2 = 4. Use 11 elements => 3 pages
|
||||||
|
// (page0: 4, page1: 4, page2: 3 short). Initialize pages 0 and 2; leave
|
||||||
|
// page 1 uninitialized. 3 pages still fits one bitmap byte, but we place
|
||||||
|
// the set bits at positions 7 and 5 to lock the MSB-first ordering.
|
||||||
|
let max_nelmts_bits = 2u8;
|
||||||
|
let page_nelmts = 1usize << max_nelmts_bits; // 4
|
||||||
|
let num_elements = 11u64;
|
||||||
|
let db_header_size = 4 + 1 + 1 + os; // FADB sig+ver+client+header_addr
|
||||||
|
let bitmap_size = 1usize; // ceil(3/8)
|
||||||
|
let page_total = page_nelmts * os + 4; // elements + checksum
|
||||||
|
|
||||||
|
let fahd_offset = 0x100usize;
|
||||||
|
let db_offset = 0x400usize;
|
||||||
|
let mut file_data = vec![0u8; 0x4000];
|
||||||
|
|
||||||
|
// FAHD
|
||||||
|
file_data[fahd_offset..fahd_offset + 4].copy_from_slice(b"FAHD");
|
||||||
|
file_data[fahd_offset + 4] = 0; // version
|
||||||
|
file_data[fahd_offset + 5] = 0; // client_id = non-filtered
|
||||||
|
file_data[fahd_offset + 6] = os as u8; // element_size = address only
|
||||||
|
file_data[fahd_offset + 7] = max_nelmts_bits;
|
||||||
|
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_elements.to_le_bytes());
|
||||||
|
file_data[fahd_offset + 16..fahd_offset + 24]
|
||||||
|
.copy_from_slice(&(db_offset as u64).to_le_bytes());
|
||||||
|
|
||||||
|
// FADB prefix
|
||||||
|
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
|
||||||
|
file_data[db_offset + 4] = 0; // version
|
||||||
|
file_data[db_offset + 5] = 0; // client_id
|
||||||
|
file_data[db_offset + 6..db_offset + 6 + os]
|
||||||
|
.copy_from_slice(&(fahd_offset as u64).to_le_bytes());
|
||||||
|
|
||||||
|
// Page-init bitmap: pages 0 and 2 initialized, page 1 not.
|
||||||
|
// MSB-first => page0 -> bit7 (0x80), page2 -> bit5 (0x20) => 0xA0.
|
||||||
|
let bitmap_off = db_offset + db_header_size;
|
||||||
|
file_data[bitmap_off] = 0b1010_0000;
|
||||||
|
|
||||||
|
// Pages start after bitmap + 4-byte checksum.
|
||||||
|
let pages_start = db_offset + db_header_size + bitmap_size + 4;
|
||||||
|
|
||||||
|
let base_addr = 0x1000u64;
|
||||||
|
// Page 0 (elements 0..4) and page 2 (elements 8..11) carry addresses;
|
||||||
|
// page 1's slot is left zero-filled and must be skipped.
|
||||||
|
for &p in &[0usize, 2usize] {
|
||||||
|
let page_off = pages_start + p * page_total;
|
||||||
|
let count = core::cmp::min(page_nelmts, num_elements as usize - p * page_nelmts);
|
||||||
|
for e in 0..count {
|
||||||
|
let i = p * page_nelmts + e;
|
||||||
|
let addr = base_addr + i as u64 * 0x100;
|
||||||
|
let pos = page_off + e * os;
|
||||||
|
file_data[pos..pos + os].copy_from_slice(&addr.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let header =
|
||||||
|
FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap();
|
||||||
|
assert_eq!(header.num_elements, 11);
|
||||||
|
|
||||||
|
let ds_dims = vec![11u64 * 20];
|
||||||
|
let chunk_dims = vec![20u32];
|
||||||
|
let chunks = read_fixed_array_chunks(
|
||||||
|
&file_data,
|
||||||
|
&header,
|
||||||
|
&ds_dims,
|
||||||
|
&chunk_dims,
|
||||||
|
8,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Page 1 (elements 4,5,6,7) is uninitialized => skipped. The remaining
|
||||||
|
// 7 chunks (0..4 and 8..11) come back with their original linear index.
|
||||||
|
assert_eq!(chunks.len(), 7);
|
||||||
|
let mut got: Vec<(u64, u64)> = chunks.iter().map(|c| (c.offsets[0], c.address)).collect();
|
||||||
|
got.sort();
|
||||||
|
let expect: Vec<(u64, u64)> = [0usize, 1, 2, 3, 8, 9, 10]
|
||||||
|
.iter()
|
||||||
|
.map(|&i| (i as u64 * 20, base_addr + i as u64 * 0x100))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(got, expect);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -379,8 +379,9 @@ impl FractalHeapHeader {
|
|||||||
// Build table of (block_size, heap_offset) for each child entry
|
// Build table of (block_size, heap_offset) for each child entry
|
||||||
let mut current_heap_offset = iblock_heap_offset;
|
let mut current_heap_offset = iblock_heap_offset;
|
||||||
|
|
||||||
// Count direct block entries vs indirect block entries
|
// Rows below max_direct_rows hold direct blocks; rows at/above hold
|
||||||
let start_indirect = self.starting_row_of_indirect_blocks as usize;
|
// child indirect blocks. (NOT the FRHP "starting rows" field.)
|
||||||
|
let start_indirect = self.max_direct_rows();
|
||||||
|
|
||||||
// Read child addresses for direct block rows
|
// Read child addresses for direct block rows
|
||||||
let max_direct_rows = nrows_usize.min(start_indirect);
|
let max_direct_rows = nrows_usize.min(start_indirect);
|
||||||
@@ -455,6 +456,25 @@ impl FractalHeapHeader {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Number of rows in the doubling table whose block size is at most the
|
||||||
|
/// maximum *direct* block size. Rows below this hold direct blocks; rows at
|
||||||
|
/// or above it hold child indirect blocks.
|
||||||
|
///
|
||||||
|
/// This is derived from the heap geometry, NOT the FRHP
|
||||||
|
/// "Starting # of Rows in Root Indirect Block" field (a constant, often 1)
|
||||||
|
/// — confusing the two makes a multi-direct-block heap unreadable.
|
||||||
|
fn max_direct_rows(&self) -> usize {
|
||||||
|
if self.starting_block_size == 0 {
|
||||||
|
return usize::MAX;
|
||||||
|
}
|
||||||
|
// Rows 0 and 1 share the starting block size; row r (r >= 1) is
|
||||||
|
// starting_block_size * 2^(r-1). The largest direct row reaches
|
||||||
|
// max_direct_block_size, giving log2(max/start) + 2 direct rows.
|
||||||
|
let ratio = (self.max_direct_block_size / self.starting_block_size).max(1);
|
||||||
|
let log2 = 63 - ratio.leading_zeros() as usize;
|
||||||
|
log2 + 2
|
||||||
|
}
|
||||||
|
|
||||||
/// Get block size for a given row in the doubling table.
|
/// Get block size for a given row in the doubling table.
|
||||||
fn block_size_for_row(&self, row: usize) -> u64 {
|
fn block_size_for_row(&self, row: usize) -> u64 {
|
||||||
let sbs = self.starting_block_size;
|
let sbs = self.starting_block_size;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use alloc::vec::Vec;
|
|||||||
use crate::error::FormatError;
|
use crate::error::FormatError;
|
||||||
|
|
||||||
/// Magic signature for global heap collections.
|
/// Magic signature for global heap collections.
|
||||||
const GCOL_SIGNATURE: [u8; 4] = [b'G', b'C', b'O', b'L'];
|
const GCOL_SIGNATURE: [u8; 4] = *b"GCOL";
|
||||||
|
|
||||||
/// A parsed global heap collection.
|
/// A parsed global heap collection.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ use crate::error::FormatError;
|
|||||||
use crate::message_type::MessageType;
|
use crate::message_type::MessageType;
|
||||||
|
|
||||||
/// OHDR signature for v2 object headers.
|
/// OHDR signature for v2 object headers.
|
||||||
const OHDR_SIGNATURE: [u8; 4] = [b'O', b'H', b'D', b'R'];
|
const OHDR_SIGNATURE: [u8; 4] = *b"OHDR";
|
||||||
|
|
||||||
/// OCHK signature for v2 continuation chunks.
|
/// OCHK signature for v2 continuation chunks.
|
||||||
const OCHK_SIGNATURE: [u8; 4] = [b'O', b'C', b'H', b'K'];
|
const OCHK_SIGNATURE: [u8; 4] = *b"OCHK";
|
||||||
|
|
||||||
/// A single parsed header message.
|
/// A single parsed header message.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -555,13 +555,12 @@ mod tests {
|
|||||||
buf.push(2); // version
|
buf.push(2); // version
|
||||||
buf.push(flags);
|
buf.push(flags);
|
||||||
|
|
||||||
if has_timestamps
|
if has_timestamps && let Some((at, mt, ct, bt)) = timestamps {
|
||||||
&& let Some((at, mt, ct, bt)) = timestamps {
|
buf.extend_from_slice(&at.to_le_bytes());
|
||||||
buf.extend_from_slice(&at.to_le_bytes());
|
buf.extend_from_slice(&mt.to_le_bytes());
|
||||||
buf.extend_from_slice(&mt.to_le_bytes());
|
buf.extend_from_slice(&ct.to_le_bytes());
|
||||||
buf.extend_from_slice(&ct.to_le_bytes());
|
buf.extend_from_slice(&bt.to_le_bytes());
|
||||||
buf.extend_from_slice(&bt.to_le_bytes());
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if flags & 0x10 != 0 {
|
if flags & 0x10 != 0 {
|
||||||
buf.extend_from_slice(&8u16.to_le_bytes()); // max_compact
|
buf.extend_from_slice(&8u16.to_le_bytes()); // max_compact
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! events. The [`DefaultProfiler`] implementation uses atomic counters for
|
//! events. The [`DefaultProfiler`] implementation uses atomic counters for
|
||||||
//! thread-safe, low-overhead profiling.
|
//! thread-safe, low-overhead profiling.
|
||||||
|
|
||||||
use core::sync::atomic::{AtomicU64, Ordering};
|
use portable_atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
/// Trait for profiling I/O operations.
|
/// Trait for profiling I/O operations.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ use alloc::{vec, vec::Vec};
|
|||||||
|
|
||||||
use core::ops::Range;
|
use core::ops::Range;
|
||||||
|
|
||||||
|
use crate::error::FormatError;
|
||||||
|
|
||||||
/// A selection describing which elements of a dataset to access.
|
/// A selection describing which elements of a dataset to access.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum Selection {
|
pub enum Selection {
|
||||||
@@ -220,6 +222,262 @@ impl Selection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decode a selection from its on-disk **`H5S_select_serialize`** form.
|
||||||
|
///
|
||||||
|
/// Returns the selection and the number of bytes consumed (selections are
|
||||||
|
/// self-describing in length, so the count lets a caller walk a packed list
|
||||||
|
/// of selections — as the Virtual Dataset global-heap block does).
|
||||||
|
///
|
||||||
|
/// Only the forms needed for VDS assembly are decoded: `ALL`, `NONE`, and
|
||||||
|
/// **regular** hyperslabs serialized at **version 3** (the encoding HDF5
|
||||||
|
/// 1.10+/2.0 emit). Point selections, irregular hyperslabs, and older
|
||||||
|
/// hyperslab versions return an error rather than mis-decoding.
|
||||||
|
pub fn decode_serialized(data: &[u8]) -> Result<(Selection, usize), FormatError> {
|
||||||
|
if data.len() < 8 {
|
||||||
|
return Err(FormatError::UnexpectedEof {
|
||||||
|
expected: 8,
|
||||||
|
available: data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let sel_type = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||||
|
let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
|
||||||
|
|
||||||
|
match sel_type {
|
||||||
|
// ALL / NONE: type(4) + version(4) + reserved(4) + length(4) = 16 bytes.
|
||||||
|
3 | 0 => {
|
||||||
|
if data.len() < 16 {
|
||||||
|
return Err(FormatError::UnexpectedEof {
|
||||||
|
expected: 16,
|
||||||
|
available: data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let sel = if sel_type == 3 {
|
||||||
|
Selection::All
|
||||||
|
} else {
|
||||||
|
Selection::None
|
||||||
|
};
|
||||||
|
Ok((sel, 16))
|
||||||
|
}
|
||||||
|
2 => decode_hyperslab_serialized(data, version),
|
||||||
|
1 => Err(FormatError::ChunkedReadError(
|
||||||
|
"VDS point selections are not supported".into(),
|
||||||
|
)),
|
||||||
|
_ => Err(FormatError::ChunkedReadError(
|
||||||
|
"unknown dataspace selection type".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enumerate the selected element indices of a **1-D** dataspace of the
|
||||||
|
/// given `extent`, in row-major selection order.
|
||||||
|
///
|
||||||
|
/// Convenience wrapper over [`Selection::iter_linear`] for rank-1 spaces.
|
||||||
|
pub fn iter_linear_1d(&self, extent: u64) -> Result<Vec<u64>, FormatError> {
|
||||||
|
self.iter_linear(&[extent])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enumerate the **row-major linear indices** of the selected elements of a
|
||||||
|
/// dataspace with shape `dims`, in row-major (C) iteration order.
|
||||||
|
///
|
||||||
|
/// This is the order HDF5 uses to pair a virtual selection with a source
|
||||||
|
/// selection in a Virtual Dataset, so the i-th index returned here for the
|
||||||
|
/// virtual selection corresponds to the i-th index for the source
|
||||||
|
/// selection. Hyperslab/point selections whose rank differs from
|
||||||
|
/// `dims.len()` are rejected.
|
||||||
|
pub fn iter_linear(&self, dims: &[u64]) -> Result<Vec<u64>, FormatError> {
|
||||||
|
let overflow = || FormatError::Overflow("VDS selection index overflow".into());
|
||||||
|
let total: u64 = dims
|
||||||
|
.iter()
|
||||||
|
.try_fold(1u64, |acc, &d| acc.checked_mul(d))
|
||||||
|
.ok_or_else(overflow)?;
|
||||||
|
// Row-major strides: row_stride[d] = product(dims[d+1..]).
|
||||||
|
let rank = dims.len();
|
||||||
|
let mut row_stride = vec![1u64; rank];
|
||||||
|
for d in (0..rank.saturating_sub(1)).rev() {
|
||||||
|
row_stride[d] = row_stride[d + 1]
|
||||||
|
.checked_mul(dims[d + 1])
|
||||||
|
.ok_or_else(overflow)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self {
|
||||||
|
Selection::All => Ok((0..total).collect()),
|
||||||
|
Selection::None => Ok(Vec::new()),
|
||||||
|
Selection::Hyperslab {
|
||||||
|
start,
|
||||||
|
stride,
|
||||||
|
count,
|
||||||
|
block,
|
||||||
|
} => {
|
||||||
|
if start.len() != rank {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"VDS selection rank does not match dataspace rank".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Selected coordinates along each dimension, in order.
|
||||||
|
let mut per_dim: Vec<Vec<u64>> = Vec::with_capacity(rank);
|
||||||
|
for d in 0..rank {
|
||||||
|
let mut coords = Vec::new();
|
||||||
|
for ci in 0..count[d] {
|
||||||
|
let base = ci
|
||||||
|
.checked_mul(stride[d])
|
||||||
|
.and_then(|o| start[d].checked_add(o))
|
||||||
|
.ok_or_else(overflow)?;
|
||||||
|
for bi in 0..block[d] {
|
||||||
|
let coord = base.checked_add(bi).ok_or_else(overflow)?;
|
||||||
|
// Anything past the extent is malformed; bail before the
|
||||||
|
// coordinate list can grow without bound.
|
||||||
|
if coord >= dims[d] {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"VDS hyperslab selection exceeds dataspace extent".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
coords.push(coord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
per_dim.push(coords);
|
||||||
|
}
|
||||||
|
if per_dim.iter().any(|c| c.is_empty()) {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
// Cartesian product in row-major order (dim 0 slowest-varying).
|
||||||
|
let out_len: usize = per_dim
|
||||||
|
.iter()
|
||||||
|
.try_fold(1usize, |acc, c| acc.checked_mul(c.len()))
|
||||||
|
.ok_or_else(overflow)?;
|
||||||
|
let mut out = Vec::with_capacity(out_len);
|
||||||
|
let mut idx = vec![0usize; rank];
|
||||||
|
loop {
|
||||||
|
let mut lin = 0u64;
|
||||||
|
for d in 0..rank {
|
||||||
|
lin = per_dim[d][idx[d]]
|
||||||
|
.checked_mul(row_stride[d])
|
||||||
|
.and_then(|o| lin.checked_add(o))
|
||||||
|
.ok_or_else(overflow)?;
|
||||||
|
}
|
||||||
|
out.push(lin);
|
||||||
|
// Increment the mixed-radix counter, last dimension fastest.
|
||||||
|
let mut carry = true;
|
||||||
|
for d in (0..rank).rev() {
|
||||||
|
idx[d] += 1;
|
||||||
|
if idx[d] < per_dim[d].len() {
|
||||||
|
carry = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
idx[d] = 0;
|
||||||
|
}
|
||||||
|
if carry {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
Selection::Points(pts) => {
|
||||||
|
let mut out = Vec::with_capacity(pts.len());
|
||||||
|
for p in pts {
|
||||||
|
if p.len() != rank {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"VDS point selection rank does not match dataspace rank".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut lin = 0u64;
|
||||||
|
for d in 0..rank {
|
||||||
|
if p[d] >= dims[d] {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"VDS point selection exceeds dataspace extent".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
lin = p[d]
|
||||||
|
.checked_mul(row_stride[d])
|
||||||
|
.and_then(|o| lin.checked_add(o))
|
||||||
|
.ok_or_else(overflow)?;
|
||||||
|
}
|
||||||
|
out.push(lin);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode an `H5S_SEL_HYPER` selection in its serialized form. Only version-3
|
||||||
|
/// **regular** hyperslabs are supported.
|
||||||
|
fn decode_hyperslab_serialized(
|
||||||
|
data: &[u8],
|
||||||
|
version: u32,
|
||||||
|
) -> Result<(Selection, usize), FormatError> {
|
||||||
|
if version != 3 {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"only version-3 hyperslab selections are supported".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// type(4) ver(4) flags(1) enc_size(1) rank(4) [start,stride,count,block]*rank
|
||||||
|
if data.len() < 14 {
|
||||||
|
return Err(FormatError::UnexpectedEof {
|
||||||
|
expected: 14,
|
||||||
|
available: data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let flags = data[8];
|
||||||
|
let enc_size = data[9] as usize;
|
||||||
|
// Bit 0 set => regular hyperslab. Irregular hyperslabs list explicit blocks.
|
||||||
|
if flags & 0x01 == 0 {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"irregular VDS hyperslab selections are not supported".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if enc_size != 2 && enc_size != 4 && enc_size != 8 {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"unsupported hyperslab coordinate encoding size".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let rank = u32::from_le_bytes([data[10], data[11], data[12], data[13]]) as usize;
|
||||||
|
// HDF5 caps dataspace rank at 32 (H5S_MAX_RANK). Reject anything larger so a
|
||||||
|
// corrupt rank can't drive a huge allocation or read loop.
|
||||||
|
if rank > 32 {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"hyperslab selection rank exceeds maximum (32)".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut pos = 14;
|
||||||
|
let read_coord = |data: &[u8], pos: usize| -> Result<u64, FormatError> {
|
||||||
|
if pos + enc_size > data.len() {
|
||||||
|
return Err(FormatError::UnexpectedEof {
|
||||||
|
expected: pos + enc_size,
|
||||||
|
available: data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut v = 0u64;
|
||||||
|
for (i, &b) in data[pos..pos + enc_size].iter().enumerate() {
|
||||||
|
v |= (b as u64) << (i * 8);
|
||||||
|
}
|
||||||
|
Ok(v)
|
||||||
|
};
|
||||||
|
let (mut start, mut stride, mut count, mut block) = (
|
||||||
|
Vec::with_capacity(rank),
|
||||||
|
Vec::with_capacity(rank),
|
||||||
|
Vec::with_capacity(rank),
|
||||||
|
Vec::with_capacity(rank),
|
||||||
|
);
|
||||||
|
for _ in 0..rank {
|
||||||
|
start.push(read_coord(data, pos)?);
|
||||||
|
pos += enc_size;
|
||||||
|
stride.push(read_coord(data, pos)?);
|
||||||
|
pos += enc_size;
|
||||||
|
count.push(read_coord(data, pos)?);
|
||||||
|
pos += enc_size;
|
||||||
|
block.push(read_coord(data, pos)?);
|
||||||
|
pos += enc_size;
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
Selection::Hyperslab {
|
||||||
|
start,
|
||||||
|
stride,
|
||||||
|
count,
|
||||||
|
block,
|
||||||
|
},
|
||||||
|
pos,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -313,4 +571,173 @@ mod tests {
|
|||||||
// Chunk [9..10] should not intersect (only row 9, but selection ends at row 8)
|
// Chunk [9..10] should not intersect (only row 9, but selection ends at row 8)
|
||||||
assert!(!sel.intersects_chunk(&[9], &[1]));
|
assert!(!sel.intersects_chunk(&[9], &[1]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_all_selection_16_bytes() {
|
||||||
|
let bytes = [3u8, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||||
|
let (sel, consumed) = Selection::decode_serialized(&bytes).unwrap();
|
||||||
|
assert_eq!(sel, Selection::All);
|
||||||
|
assert_eq!(consumed, 16);
|
||||||
|
assert_eq!(sel.iter_linear_1d(4).unwrap(), vec![0, 1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_regular_hyperslab_matches_vds_fixture() {
|
||||||
|
// Exact virtual selection for src_a in the VDS fixture:
|
||||||
|
// start=0 stride=1 count=1 block=4, version 3, enc_size 2, rank 1.
|
||||||
|
let bytes = [
|
||||||
|
0x02, 0, 0, 0, // type = HYPER
|
||||||
|
0x03, 0, 0, 0, // version 3
|
||||||
|
0x01, // flags = regular
|
||||||
|
0x02, // enc_size = 2
|
||||||
|
0x01, 0, 0, 0, // rank = 1
|
||||||
|
0x00, 0x00, // start
|
||||||
|
0x01, 0x00, // stride
|
||||||
|
0x01, 0x00, // count
|
||||||
|
0x04, 0x00, // block
|
||||||
|
];
|
||||||
|
let (sel, consumed) = Selection::decode_serialized(&bytes).unwrap();
|
||||||
|
assert_eq!(consumed, 22);
|
||||||
|
assert_eq!(
|
||||||
|
sel,
|
||||||
|
Selection::Hyperslab {
|
||||||
|
start: vec![0],
|
||||||
|
stride: vec![1],
|
||||||
|
count: vec![1],
|
||||||
|
block: vec![4],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(sel.iter_linear_1d(8).unwrap(), vec![0, 1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_hyperslab_start4() {
|
||||||
|
let bytes = [
|
||||||
|
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x01, 0, 0, 0, //
|
||||||
|
0x04, 0x00, 0x01, 0x00, 0x01, 0x00, 0x04, 0x00,
|
||||||
|
];
|
||||||
|
let (sel, _) = Selection::decode_serialized(&bytes).unwrap();
|
||||||
|
assert_eq!(sel.iter_linear_1d(8).unwrap(), vec![4, 5, 6, 7]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_strided_hyperslab_iter() {
|
||||||
|
// start=1 stride=3 count=2 block=2 => 1,2, 4,5
|
||||||
|
let bytes = [
|
||||||
|
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x01, 0, 0, 0, //
|
||||||
|
0x01, 0x00, 0x03, 0x00, 0x02, 0x00, 0x02, 0x00,
|
||||||
|
];
|
||||||
|
let (sel, _) = Selection::decode_serialized(&bytes).unwrap();
|
||||||
|
assert_eq!(sel.iter_linear_1d(8).unwrap(), vec![1, 2, 4, 5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_nd_hyperslab_iter_rejected() {
|
||||||
|
let bytes = [
|
||||||
|
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x02, 0, 0, 0, // rank 2
|
||||||
|
0, 0, 1, 0, 1, 0, 2, 0, 0, 0, 1, 0, 1, 0, 2, 0,
|
||||||
|
];
|
||||||
|
let (sel, _) = Selection::decode_serialized(&bytes).unwrap();
|
||||||
|
assert!(sel.iter_linear_1d(16).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_irregular_hyperslab_rejected() {
|
||||||
|
let bytes = [0x02u8, 0, 0, 0, 0x03, 0, 0, 0, 0x00, 0x02, 0x01, 0, 0, 0];
|
||||||
|
assert!(Selection::decode_serialized(&bytes).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iter_linear_2d_block_row_major() {
|
||||||
|
// A 2x2 block at the top-left of a 4x4 space => linear 0,1,4,5.
|
||||||
|
let sel = Selection::Hyperslab {
|
||||||
|
start: vec![0, 0],
|
||||||
|
stride: vec![1, 1],
|
||||||
|
count: vec![1, 1],
|
||||||
|
block: vec![2, 2],
|
||||||
|
};
|
||||||
|
assert_eq!(sel.iter_linear(&[4, 4]).unwrap(), vec![0, 1, 4, 5]);
|
||||||
|
|
||||||
|
// The same block shifted to the bottom-right => 10,11,14,15.
|
||||||
|
let sel2 = Selection::Hyperslab {
|
||||||
|
start: vec![2, 2],
|
||||||
|
stride: vec![1, 1],
|
||||||
|
count: vec![1, 1],
|
||||||
|
block: vec![2, 2],
|
||||||
|
};
|
||||||
|
assert_eq!(sel2.iter_linear(&[4, 4]).unwrap(), vec![10, 11, 14, 15]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iter_linear_2d_strided() {
|
||||||
|
// start=(0,0) stride=(2,2) count=(2,2) block=(1,1) over 4x4 =>
|
||||||
|
// coords (0,0)(0,2)(2,0)(2,2) => linear 0,2,8,10.
|
||||||
|
let sel = Selection::Hyperslab {
|
||||||
|
start: vec![0, 0],
|
||||||
|
stride: vec![2, 2],
|
||||||
|
count: vec![2, 2],
|
||||||
|
block: vec![1, 1],
|
||||||
|
};
|
||||||
|
assert_eq!(sel.iter_linear(&[4, 4]).unwrap(), vec![0, 2, 8, 10]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iter_linear_all_2d() {
|
||||||
|
assert_eq!(
|
||||||
|
Selection::All.iter_linear(&[2, 3]).unwrap(),
|
||||||
|
(0..6).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iter_linear_rank_mismatch_rejected() {
|
||||||
|
let sel = Selection::Hyperslab {
|
||||||
|
start: vec![0],
|
||||||
|
stride: vec![1],
|
||||||
|
count: vec![1],
|
||||||
|
block: vec![2],
|
||||||
|
};
|
||||||
|
assert!(sel.iter_linear(&[4, 4]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Adversarial / hardening: malformed input must error, never panic -----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_all_truncated_does_not_overrun() {
|
||||||
|
// ALL claims to consume 16 bytes but only 8 are present.
|
||||||
|
let bytes = [3u8, 0, 0, 0, 1, 0, 0, 0];
|
||||||
|
assert!(Selection::decode_serialized(&bytes).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_hyperslab_huge_rank_rejected() {
|
||||||
|
// rank = 0xFFFFFFFF must not drive a giant allocation.
|
||||||
|
let bytes = [
|
||||||
|
0x02u8, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||||
|
];
|
||||||
|
assert!(Selection::decode_serialized(&bytes).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iter_linear_hyperslab_overflow_is_error() {
|
||||||
|
// start/stride/count near u64::MAX must not panic on multiply/add.
|
||||||
|
let sel = Selection::Hyperslab {
|
||||||
|
start: vec![u64::MAX - 1],
|
||||||
|
stride: vec![u64::MAX],
|
||||||
|
count: vec![u64::MAX],
|
||||||
|
block: vec![u64::MAX],
|
||||||
|
};
|
||||||
|
assert!(sel.iter_linear(&[100]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iter_linear_dims_product_overflow_is_error() {
|
||||||
|
assert!(Selection::All.iter_linear(&[u64::MAX, u64::MAX]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_empty_or_short_is_error_not_panic() {
|
||||||
|
assert!(Selection::decode_serialized(&[]).is_err());
|
||||||
|
assert!(Selection::decode_serialized(&[2, 0, 0, 0, 3, 0]).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ pub struct Superblock {
|
|||||||
pub superblock_extension_address: Option<u64>,
|
pub superblock_extension_address: Option<u64>,
|
||||||
/// CRC32C checksum (v2/v3 only).
|
/// CRC32C checksum (v2/v3 only).
|
||||||
pub checksum: Option<u32>,
|
pub checksum: Option<u32>,
|
||||||
|
/// Page size for page-buffer mode (v4 only). `None` for v0–v3.
|
||||||
|
pub page_size: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read an unsigned integer of `size` bytes (LE) from `data` at `pos`.
|
/// Read an unsigned integer of `size` bytes (LE) from `data` at `pos`.
|
||||||
@@ -125,7 +127,8 @@ impl Superblock {
|
|||||||
|
|
||||||
/// Serialize this superblock to bytes.
|
/// Serialize this superblock to bytes.
|
||||||
///
|
///
|
||||||
/// Always writes v2/v3 format. Computes and appends Jenkins lookup3 checksum.
|
/// Writes v2/v3 format, or v4 (with `page_size`) when `self.version == 4`.
|
||||||
|
/// Computes and appends Jenkins lookup3 checksum.
|
||||||
pub fn serialize(&self) -> Vec<u8> {
|
pub fn serialize(&self) -> Vec<u8> {
|
||||||
let mut buf = Vec::with_capacity(48);
|
let mut buf = Vec::with_capacity(48);
|
||||||
buf.extend_from_slice(&HDF5_SIGNATURE);
|
buf.extend_from_slice(&HDF5_SIGNATURE);
|
||||||
@@ -142,6 +145,11 @@ impl Superblock {
|
|||||||
Self::write_offset(&mut buf, self.eof_address, self.offset_size);
|
Self::write_offset(&mut buf, self.eof_address, self.offset_size);
|
||||||
// root_group_address
|
// root_group_address
|
||||||
Self::write_offset(&mut buf, self.root_group_address, self.offset_size);
|
Self::write_offset(&mut buf, self.root_group_address, self.offset_size);
|
||||||
|
// page_size (v4 only)
|
||||||
|
if self.version >= 4 {
|
||||||
|
let ps = self.page_size.unwrap_or(0);
|
||||||
|
buf.extend_from_slice(&ps.to_le_bytes());
|
||||||
|
}
|
||||||
// checksum
|
// checksum
|
||||||
let checksum = crate::checksum::jenkins_lookup3(&buf);
|
let checksum = crate::checksum::jenkins_lookup3(&buf);
|
||||||
buf.extend_from_slice(&checksum.to_le_bytes());
|
buf.extend_from_slice(&checksum.to_le_bytes());
|
||||||
@@ -179,6 +187,7 @@ impl Superblock {
|
|||||||
0 => Self::parse_v0(d),
|
0 => Self::parse_v0(d),
|
||||||
1 => Self::parse_v1(d),
|
1 => Self::parse_v1(d),
|
||||||
2 | 3 => Self::parse_v2v3(d, version),
|
2 | 3 => Self::parse_v2v3(d, version),
|
||||||
|
4 => Self::parse_v4(d),
|
||||||
v => Err(FormatError::UnsupportedVersion(v)),
|
v => Err(FormatError::UnsupportedVersion(v)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,6 +244,7 @@ impl Superblock {
|
|||||||
consistency_flags,
|
consistency_flags,
|
||||||
superblock_extension_address: None,
|
superblock_extension_address: None,
|
||||||
checksum: None,
|
checksum: None,
|
||||||
|
page_size: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +302,7 @@ impl Superblock {
|
|||||||
consistency_flags,
|
consistency_flags,
|
||||||
superblock_extension_address: None,
|
superblock_extension_address: None,
|
||||||
checksum: None,
|
checksum: None,
|
||||||
|
page_size: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,6 +359,71 @@ impl Superblock {
|
|||||||
consistency_flags,
|
consistency_flags,
|
||||||
superblock_extension_address: Some(superblock_extension_address),
|
superblock_extension_address: Some(superblock_extension_address),
|
||||||
checksum: Some(stored_checksum),
|
checksum: Some(stored_checksum),
|
||||||
|
page_size: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_v4(d: &[u8]) -> Result<Superblock, FormatError> {
|
||||||
|
// Same layout as v2/v3, plus page_size(4) inserted before the checksum.
|
||||||
|
ensure_len(d, 12)?;
|
||||||
|
|
||||||
|
let offset_size = d[9];
|
||||||
|
let length_size = d[10];
|
||||||
|
validate_sizes(offset_size, length_size)?;
|
||||||
|
let consistency_flags = d[11] as u32;
|
||||||
|
|
||||||
|
let os = offset_size as usize;
|
||||||
|
// 4 addresses + page_size(4) + checksum(4)
|
||||||
|
let total = 12 + 4 * os + 4 + 4;
|
||||||
|
ensure_len(d, total)?;
|
||||||
|
|
||||||
|
let mut pos = 12;
|
||||||
|
let base_address = read_offset(d, pos, offset_size)?;
|
||||||
|
pos += os;
|
||||||
|
let superblock_extension_address = read_offset(d, pos, offset_size)?;
|
||||||
|
pos += os;
|
||||||
|
let eof_address = read_offset(d, pos, offset_size)?;
|
||||||
|
pos += os;
|
||||||
|
let root_group_address = read_offset(d, pos, offset_size)?;
|
||||||
|
pos += os;
|
||||||
|
|
||||||
|
let page_size = LittleEndian::read_u32(&d[pos..pos + 4]);
|
||||||
|
pos += 4;
|
||||||
|
|
||||||
|
let stored_checksum = LittleEndian::read_u32(&d[pos..pos + 4]);
|
||||||
|
pos += 4;
|
||||||
|
|
||||||
|
#[cfg(feature = "checksum")]
|
||||||
|
{
|
||||||
|
let computed = crate::checksum::jenkins_lookup3(&d[..pos - 4]);
|
||||||
|
if computed != stored_checksum {
|
||||||
|
return Err(FormatError::ChecksumMismatch {
|
||||||
|
expected: stored_checksum,
|
||||||
|
computed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "checksum"))]
|
||||||
|
{
|
||||||
|
let _ = pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Superblock {
|
||||||
|
version: 4,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
base_address,
|
||||||
|
eof_address,
|
||||||
|
root_group_address,
|
||||||
|
group_leaf_node_k: None,
|
||||||
|
group_internal_node_k: None,
|
||||||
|
indexed_storage_internal_node_k: None,
|
||||||
|
free_space_address: None,
|
||||||
|
driver_info_address: None,
|
||||||
|
consistency_flags,
|
||||||
|
superblock_extension_address: Some(superblock_extension_address),
|
||||||
|
checksum: Some(stored_checksum),
|
||||||
|
page_size: Some(page_size),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -652,4 +728,84 @@ mod tests {
|
|||||||
let new_eof = sb.refresh_eof(&data, 0).unwrap();
|
let new_eof = sb.refresh_eof(&data, 0).unwrap();
|
||||||
assert_eq!(new_eof, old_eof);
|
assert_eq!(new_eof, old_eof);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_v4_with_page_size() {
|
||||||
|
// Superblock v4 = v2/v3 layout + page_size(4) before checksum.
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
buf.extend_from_slice(&HDF5_SIGNATURE);
|
||||||
|
buf.push(4); // version = 4
|
||||||
|
buf.push(8); // offset_size
|
||||||
|
buf.push(8); // length_size
|
||||||
|
buf.push(0); // consistency_flags
|
||||||
|
write_offset(&mut buf, 0, 8); // base_address
|
||||||
|
write_offset(&mut buf, u64::MAX, 8); // superblock_extension_address = UNDEF
|
||||||
|
write_offset(&mut buf, 512, 8); // eof_address
|
||||||
|
write_offset(&mut buf, 96, 8); // root_group_address
|
||||||
|
buf.extend_from_slice(&4096u32.to_le_bytes()); // page_size (v4 addition)
|
||||||
|
let checksum = crate::checksum::jenkins_lookup3(&buf);
|
||||||
|
buf.extend_from_slice(&checksum.to_le_bytes());
|
||||||
|
|
||||||
|
let sb = Superblock::parse(&buf, 0).unwrap();
|
||||||
|
assert_eq!(sb.version, 4);
|
||||||
|
assert_eq!(sb.offset_size, 8);
|
||||||
|
assert_eq!(sb.eof_address, 512);
|
||||||
|
assert_eq!(sb.root_group_address, 96);
|
||||||
|
assert_eq!(sb.page_size, Some(4096));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialize_v4_roundtrip() {
|
||||||
|
let sb = Superblock {
|
||||||
|
version: 4,
|
||||||
|
offset_size: 8,
|
||||||
|
length_size: 8,
|
||||||
|
base_address: 0,
|
||||||
|
eof_address: 1024,
|
||||||
|
root_group_address: 96,
|
||||||
|
group_leaf_node_k: None,
|
||||||
|
group_internal_node_k: None,
|
||||||
|
indexed_storage_internal_node_k: None,
|
||||||
|
free_space_address: None,
|
||||||
|
driver_info_address: None,
|
||||||
|
consistency_flags: 0,
|
||||||
|
superblock_extension_address: Some(u64::MAX),
|
||||||
|
checksum: None,
|
||||||
|
page_size: Some(4096),
|
||||||
|
};
|
||||||
|
let bytes = sb.serialize();
|
||||||
|
let parsed = Superblock::parse(&bytes, 0).unwrap();
|
||||||
|
assert_eq!(parsed.version, 4);
|
||||||
|
assert_eq!(parsed.page_size, Some(4096));
|
||||||
|
assert_eq!(parsed.eof_address, 1024);
|
||||||
|
assert_eq!(parsed.root_group_address, 96);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialize_v3_unchanged_by_page_size_field() {
|
||||||
|
// v3 (page_size: None) must serialize identically to before this feature existed.
|
||||||
|
let sb = Superblock {
|
||||||
|
version: 3,
|
||||||
|
offset_size: 8,
|
||||||
|
length_size: 8,
|
||||||
|
base_address: 0,
|
||||||
|
eof_address: 2048,
|
||||||
|
root_group_address: 96,
|
||||||
|
group_leaf_node_k: None,
|
||||||
|
group_internal_node_k: None,
|
||||||
|
indexed_storage_internal_node_k: None,
|
||||||
|
free_space_address: None,
|
||||||
|
driver_info_address: None,
|
||||||
|
consistency_flags: 0,
|
||||||
|
superblock_extension_address: Some(u64::MAX),
|
||||||
|
checksum: None,
|
||||||
|
page_size: None,
|
||||||
|
};
|
||||||
|
let bytes = sb.serialize();
|
||||||
|
// sig(8) + version/offset/length/flags(4) + 4 addresses(8 each) + checksum(4)
|
||||||
|
assert_eq!(bytes.len(), 8 + 4 + 4 * 8 + 4);
|
||||||
|
let parsed = Superblock::parse(&bytes, 0).unwrap();
|
||||||
|
assert_eq!(parsed.version, 3);
|
||||||
|
assert_eq!(parsed.page_size, None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use alloc::{boxed::Box, string::String, string::ToString, vec, vec::Vec};
|
|||||||
|
|
||||||
use crate::attribute::AttributeMessage;
|
use crate::attribute::AttributeMessage;
|
||||||
use crate::chunked_write::ChunkOptions;
|
use crate::chunked_write::ChunkOptions;
|
||||||
|
use crate::data_layout::VdsMapping;
|
||||||
use crate::dataspace::{Dataspace, DataspaceType};
|
use crate::dataspace::{Dataspace, DataspaceType};
|
||||||
use crate::datatype::{
|
use crate::datatype::{
|
||||||
CharacterSet, CompoundMember, Datatype, DatatypeByteOrder, EnumMember, StringPadding,
|
CharacterSet, CompoundMember, Datatype, DatatypeByteOrder, EnumMember, StringPadding,
|
||||||
@@ -362,6 +363,12 @@ pub struct DatasetBuilder {
|
|||||||
pub(crate) compact: bool,
|
pub(crate) compact: bool,
|
||||||
/// Per-dataset alignment in bytes (0 = no special alignment).
|
/// Per-dataset alignment in bytes (0 = no special alignment).
|
||||||
pub(crate) alignment: usize,
|
pub(crate) alignment: usize,
|
||||||
|
/// Virtual Dataset (VDS) source mappings.
|
||||||
|
///
|
||||||
|
/// When set, this dataset uses Virtual Dataset layout (v4 class 3). The
|
||||||
|
/// `data` field is ignored; instead the global heap blob is built from
|
||||||
|
/// these mappings and a VDS layout message is emitted.
|
||||||
|
pub(crate) virtual_sources: Option<Vec<VdsMapping>>,
|
||||||
#[cfg(feature = "provenance")]
|
#[cfg(feature = "provenance")]
|
||||||
pub(crate) provenance: Option<ProvenanceConfig>,
|
pub(crate) provenance: Option<ProvenanceConfig>,
|
||||||
}
|
}
|
||||||
@@ -379,6 +386,7 @@ impl DatasetBuilder {
|
|||||||
fill_time: FillTime::default(),
|
fill_time: FillTime::default(),
|
||||||
compact: false,
|
compact: false,
|
||||||
alignment: 0,
|
alignment: 0,
|
||||||
|
virtual_sources: None,
|
||||||
#[cfg(feature = "provenance")]
|
#[cfg(feature = "provenance")]
|
||||||
provenance: None,
|
provenance: None,
|
||||||
}
|
}
|
||||||
@@ -534,6 +542,11 @@ impl DatasetBuilder {
|
|||||||
|
|
||||||
/// Enable zstd compression at `level` (1-22). HDF5 filter ID 32015.
|
/// Enable zstd compression at `level` (1-22). HDF5 filter ID 32015.
|
||||||
/// Implies chunked storage. Requires the `zstd` cargo feature.
|
/// Implies chunked storage. Requires the `zstd` cargo feature.
|
||||||
|
///
|
||||||
|
/// **Recommended for write-heavy workloads:** Zstd level 3 encodes at
|
||||||
|
/// ~500+ MiB/s vs deflate's ~300 MiB/s at the same or better compression
|
||||||
|
/// ratio (see arXiv 2604.06221). Shuffle is applied automatically before
|
||||||
|
/// compression; call `.without_shuffle()` to disable it.
|
||||||
pub fn with_zstd(&mut self, level: u32) -> &mut Self {
|
pub fn with_zstd(&mut self, level: u32) -> &mut Self {
|
||||||
self.chunk_options.zstd_level = Some(level);
|
self.chunk_options.zstd_level = Some(level);
|
||||||
self
|
self
|
||||||
@@ -546,12 +559,35 @@ impl DatasetBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable Pcodec lossless numerical compression (clawhdf5 filter ID 32023).
|
||||||
|
///
|
||||||
|
/// Pcodec achieves 30–94% better compression ratio than Zstd for f32/f64
|
||||||
|
/// columns at 1–5 GiB/s decompression speed (arXiv:2502.06112). Requires
|
||||||
|
/// the `pcodec` cargo feature.
|
||||||
|
pub fn with_pcodec(&mut self) -> &mut Self {
|
||||||
|
self.chunk_options.pcodec = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Enable shuffle filter (usually combined with deflate or zstd).
|
/// Enable shuffle filter (usually combined with deflate or zstd).
|
||||||
|
/// Note: shuffle is auto-applied before any compression codec by default.
|
||||||
pub fn with_shuffle(&mut self) -> &mut Self {
|
pub fn with_shuffle(&mut self) -> &mut Self {
|
||||||
self.chunk_options.shuffle = true;
|
self.chunk_options.shuffle = true;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Disable the automatic shuffle pre-filter.
|
||||||
|
///
|
||||||
|
/// By default, the shuffle filter is applied before any compression codec
|
||||||
|
/// (deflate, Zstd, LZ4, Pcodec) to improve compression ratios on float/int
|
||||||
|
/// arrays. Call this to disable it, e.g. for already-shuffled data or when
|
||||||
|
/// storing byte arrays where shuffle hurts compression.
|
||||||
|
pub fn without_shuffle(&mut self) -> &mut Self {
|
||||||
|
self.chunk_options.no_shuffle = true;
|
||||||
|
self.chunk_options.shuffle = false;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Enable fletcher32 checksum.
|
/// Enable fletcher32 checksum.
|
||||||
pub fn with_fletcher32(&mut self) -> &mut Self {
|
pub fn with_fletcher32(&mut self) -> &mut Self {
|
||||||
self.chunk_options.fletcher32 = true;
|
self.chunk_options.fletcher32 = true;
|
||||||
@@ -586,6 +622,23 @@ impl DatasetBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configure this dataset as a Virtual Dataset (VDS).
|
||||||
|
///
|
||||||
|
/// The supplied `mappings` list describes each source → virtual region
|
||||||
|
/// correspondence. The dataset will use HDF5 layout class 3 (Virtual).
|
||||||
|
/// Any previously set `data` is ignored when virtual sources are present.
|
||||||
|
///
|
||||||
|
/// `datatype` and `shape` must still be set via `with_*_data()` or
|
||||||
|
/// `with_shape()` / `with_f64_data()` etc.; the actual raw bytes are
|
||||||
|
/// 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 {
|
||||||
|
if !mappings.is_empty() {
|
||||||
|
self.virtual_sources = Some(mappings);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Attach SHINES provenance metadata (SHA-256, creator, timestamp).
|
/// Attach SHINES provenance metadata (SHA-256, creator, timestamp).
|
||||||
///
|
///
|
||||||
/// The SHA-256 hash of the raw dataset bytes is computed automatically
|
/// The SHA-256 hash of the raw dataset bytes is computed automatically
|
||||||
@@ -613,6 +666,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 {
|
||||||
@@ -621,6 +676,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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,12 +689,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -648,4 +720,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)>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,9 +321,9 @@ fn roundtrip_through_file_writer() {
|
|||||||
&& let clawhdf5_format::link_message::LinkTarget::Hard {
|
&& let clawhdf5_format::link_message::LinkTarget::Hard {
|
||||||
object_header_address,
|
object_header_address,
|
||||||
} = link.link_target
|
} = link.link_target
|
||||||
{
|
{
|
||||||
ds_addr = Some(object_header_address);
|
ds_addr = Some(object_header_address);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let ds_addr = ds_addr.expect("compound_ds link not found");
|
let ds_addr = ds_addr.expect("compound_ds link not found");
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -662,6 +662,209 @@ fn v4_fixed_array_read() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v4_virtual_dataset_same_file_read() {
|
||||||
|
// A 1-D virtual dataset assembled from two same-file sources:
|
||||||
|
// virt[0:4] <- src_a[1:5] (partial source hyperslab) => 11,12,13,14
|
||||||
|
// virt[4:8] <- (unmapped) => fill 0
|
||||||
|
// virt[8:12] <- src_b[0:4] (ALL) => 20,21,22,23
|
||||||
|
let file_data = include_bytes!("fixtures/vds_same_file.h5");
|
||||||
|
let (raw, datatype, _) = read_chunked_dataset(file_data, "virt");
|
||||||
|
let values = read_as_i32(&raw, &datatype).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
values,
|
||||||
|
vec![11, 12, 13, 14, 0, 0, 0, 0, 20, 21, 22, 23],
|
||||||
|
"VDS assembly (partial source slice + fill gap) mismatch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v4_virtual_dataset_2d_same_file_read() {
|
||||||
|
// A 4x4 virtual dataset assembled from two 2x2 same-file sources placed as
|
||||||
|
// non-contiguous blocks (exercises N-dimensional row-major scatter):
|
||||||
|
// virt[0:2,0:2] <- src_a = [[1,2],[3,4]]
|
||||||
|
// virt[2:4,2:4] <- src_b = [[5,6],[7,8]]
|
||||||
|
// everything else -> fill 0
|
||||||
|
let file_data = include_bytes!("fixtures/vds_2d_same_file.h5");
|
||||||
|
let (raw, datatype, _) = read_chunked_dataset(file_data, "virt");
|
||||||
|
let values = read_as_i32(&raw, &datatype).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
values,
|
||||||
|
vec![1, 2, 0, 0, 3, 4, 0, 0, 0, 0, 5, 6, 0, 0, 7, 8],
|
||||||
|
"2-D VDS block scatter mismatch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scaleoffset_float_escale_reads_as_raw() {
|
||||||
|
// The scale-offset filter's floating-point *E-scale* mode (cd_values[0] = 1)
|
||||||
|
// is not actually implemented by the HDF5 library: when asked for it, HDF5
|
||||||
|
// stores the chunk raw (no minbits/minval header) and sets the chunk filter
|
||||||
|
// mask to skip the filter. So such a dataset must read back verbatim purely
|
||||||
|
// by honoring the per-chunk filter mask — no E-scale decoder is needed.
|
||||||
|
let file_data = include_bytes!("fixtures/scaleoffset_float_escale.h5");
|
||||||
|
let (raw, datatype, _) = read_chunked_dataset(file_data, "x");
|
||||||
|
let values = read_as_f64(&raw, &datatype).unwrap();
|
||||||
|
let expect: Vec<f64> = (0..20).map(|i| i as f64 * 0.25).collect();
|
||||||
|
assert_eq!(
|
||||||
|
values, expect,
|
||||||
|
"E-scale (raw + masked filter) must read verbatim"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v4_virtual_dataset_cycle_errors_not_overflow() {
|
||||||
|
// virt -> virt2 -> virt (both virtual, same file). The reader must reject
|
||||||
|
// the nested virtual source rather than recurse into a stack overflow.
|
||||||
|
let file_data = include_bytes!("fixtures/vds_cyclic.h5");
|
||||||
|
let offset = find_signature(file_data).unwrap();
|
||||||
|
let sb = Superblock::parse(file_data, offset).unwrap();
|
||||||
|
let addr = resolve_path_any(file_data, &sb, "virt").unwrap();
|
||||||
|
let hdr =
|
||||||
|
ObjectHeader::parse(file_data, addr as usize, sb.offset_size, sb.length_size).unwrap();
|
||||||
|
let ds = Dataspace::parse(
|
||||||
|
&hdr.messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.msg_type == MessageType::Dataspace)
|
||||||
|
.unwrap()
|
||||||
|
.data,
|
||||||
|
sb.length_size,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (dt, _) = Datatype::parse(
|
||||||
|
&hdr.messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.msg_type == MessageType::Datatype)
|
||||||
|
.unwrap()
|
||||||
|
.data,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let layout = DataLayout::parse(
|
||||||
|
&hdr.messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.msg_type == MessageType::DataLayout)
|
||||||
|
.unwrap()
|
||||||
|
.data,
|
||||||
|
sb.offset_size,
|
||||||
|
sb.length_size,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let r = read_raw_data_full(
|
||||||
|
file_data,
|
||||||
|
&layout,
|
||||||
|
&ds,
|
||||||
|
&dt,
|
||||||
|
None,
|
||||||
|
sb.offset_size,
|
||||||
|
sb.length_size,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
r.is_err(),
|
||||||
|
"cyclic virtual dataset must error, not overflow"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v4_virtual_dataset_external_file_read() {
|
||||||
|
use clawhdf5_format::data_read::read_raw_data_full_with_resolver;
|
||||||
|
// The virtual file maps virt[0:8] <- (external) ext_src.h5:/data = [10..17].
|
||||||
|
let virt = include_bytes!("fixtures/vds_external_virt.h5");
|
||||||
|
let src = include_bytes!("fixtures/vds_external_src.h5").to_vec();
|
||||||
|
|
||||||
|
let sig = find_signature(virt).unwrap();
|
||||||
|
let sb = Superblock::parse(virt, sig).unwrap();
|
||||||
|
let addr = resolve_path_any(virt, &sb, "virt").unwrap();
|
||||||
|
let hdr = ObjectHeader::parse(virt, addr as usize, sb.offset_size, sb.length_size).unwrap();
|
||||||
|
let ds = Dataspace::parse(
|
||||||
|
&hdr.messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.msg_type == MessageType::Dataspace)
|
||||||
|
.unwrap()
|
||||||
|
.data,
|
||||||
|
sb.length_size,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (dt, _) = Datatype::parse(
|
||||||
|
&hdr.messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.msg_type == MessageType::Datatype)
|
||||||
|
.unwrap()
|
||||||
|
.data,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let layout = DataLayout::parse(
|
||||||
|
&hdr.messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.msg_type == MessageType::DataLayout)
|
||||||
|
.unwrap()
|
||||||
|
.data,
|
||||||
|
sb.offset_size,
|
||||||
|
sb.length_size,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Resolver supplies the external source file's bytes by its stored name.
|
||||||
|
let resolver = |name: &str| -> Option<Vec<u8>> {
|
||||||
|
if name == "ext_src.h5" {
|
||||||
|
Some(src.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let raw = read_raw_data_full_with_resolver(
|
||||||
|
virt,
|
||||||
|
&layout,
|
||||||
|
&ds,
|
||||||
|
&dt,
|
||||||
|
None,
|
||||||
|
sb.offset_size,
|
||||||
|
sb.length_size,
|
||||||
|
Some(&resolver),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let values = read_as_i32(&raw, &dt).unwrap();
|
||||||
|
assert_eq!(values, vec![10, 11, 12, 13, 14, 15, 16, 17]);
|
||||||
|
|
||||||
|
// With no resolver, an external source is a clean error (not wrong data).
|
||||||
|
let no_resolver = read_raw_data_full_with_resolver(
|
||||||
|
virt,
|
||||||
|
&layout,
|
||||||
|
&ds,
|
||||||
|
&dt,
|
||||||
|
None,
|
||||||
|
sb.offset_size,
|
||||||
|
sb.length_size,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(no_resolver.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v4_paged_fixed_array_read() {
|
||||||
|
// 1025 chunks of 16 int32s, gzip-filtered => Fixed Array index whose data
|
||||||
|
// block is *paged* (page holds 1024 elements). Page 0 is full, page 1 holds
|
||||||
|
// the single trailing chunk. Chunk k stores value k at its first element.
|
||||||
|
let file_data = include_bytes!("fixtures/v4_fixed_array_paged.h5");
|
||||||
|
let (raw, datatype, _) = read_chunked_dataset(file_data, "big");
|
||||||
|
let values = read_as_i32(&raw, &datatype).unwrap();
|
||||||
|
assert_eq!(values.len(), 1025 * 16);
|
||||||
|
for k in 0..1025usize {
|
||||||
|
assert_eq!(
|
||||||
|
values[k * 16],
|
||||||
|
k as i32,
|
||||||
|
"chunk-start mismatch at chunk {k}"
|
||||||
|
);
|
||||||
|
for j in 1..16 {
|
||||||
|
assert_eq!(
|
||||||
|
values[k * 16 + j],
|
||||||
|
0,
|
||||||
|
"non-start element nonzero at {}",
|
||||||
|
k * 16 + j
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn v4_2d_fixed_array_read() {
|
fn v4_2d_fixed_array_read() {
|
||||||
let file_data = include_bytes!("fixtures/v4_2d.h5");
|
let file_data = include_bytes!("fixtures/v4_2d.h5");
|
||||||
|
|||||||
@@ -214,9 +214,9 @@ print('ok')
|
|||||||
&& let clawhdf5_format::link_message::LinkTarget::Hard {
|
&& let clawhdf5_format::link_message::LinkTarget::Hard {
|
||||||
object_header_address,
|
object_header_address,
|
||||||
} = link.link_target
|
} = link.link_target
|
||||||
{
|
{
|
||||||
refs_addr = Some(object_header_address);
|
refs_addr = Some(object_header_address);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# rustyhdf5-gpu
|
# clawhdf5-gpu
|
||||||
|
|
||||||
[](https://crates.io/crates/rustyhdf5-gpu)
|
[](https://crates.io/crates/clawhdf5-gpu)
|
||||||
[](https://docs.rs/rustyhdf5-gpu)
|
[](https://docs.rs/clawhdf5-gpu)
|
||||||
|
|
||||||
GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders.
|
GPU-accelerated vector operations for clawhdf5 using wgpu compute shaders.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use rustyhdf5_gpu::GpuAccelerator;
|
use clawhdf5_gpu::GpuAccelerator;
|
||||||
|
|
||||||
let accel = GpuAccelerator::new().unwrap();
|
let accel = GpuAccelerator::new().unwrap();
|
||||||
let distances = accel.l2_distances(&query, &vectors).unwrap();
|
let distances = accel.l2_distances(&query, &vectors).unwrap();
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# rustyhdf5-io
|
# clawhdf5-io
|
||||||
|
|
||||||
[](https://crates.io/crates/rustyhdf5-io)
|
[](https://crates.io/crates/clawhdf5-io)
|
||||||
[](https://docs.rs/rustyhdf5-io)
|
[](https://docs.rs/clawhdf5-io)
|
||||||
|
|
||||||
I/O abstraction layer for rustyhdf5.
|
I/O abstraction layer for clawhdf5.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ I/O abstraction layer for rustyhdf5.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use rustyhdf5_io::MmapReader;
|
use clawhdf5_io::MmapReader;
|
||||||
|
|
||||||
let reader = MmapReader::open("data.h5").unwrap();
|
let reader = MmapReader::open("data.h5").unwrap();
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -231,6 +231,30 @@ impl FileWriter {
|
|||||||
pub fn path(&self) -> &std::path::Path {
|
pub fn path(&self) -> &std::path::Path {
|
||||||
&self.path
|
&self.path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write `data` into this writer, taking ownership to avoid a copy.
|
||||||
|
///
|
||||||
|
/// Prefer over [`HDF5ReadWrite::write_all_bytes`] when the caller already
|
||||||
|
/// owns a `Vec<u8>` (e.g., from `FileWriter::finish()`).
|
||||||
|
pub fn write_bytes_owned(&mut self, data: Vec<u8>) -> io::Result<()> {
|
||||||
|
self.data = data;
|
||||||
|
if let Some(ref mut interceptor) = self.interceptor {
|
||||||
|
let ps = self.page_size as usize;
|
||||||
|
if ps > 0 {
|
||||||
|
let mut offset: u64 = 0;
|
||||||
|
let mut pos = 0usize;
|
||||||
|
while pos + ps <= self.data.len() {
|
||||||
|
interceptor.on_page_write(offset, &self.data[pos..pos + ps]);
|
||||||
|
pos += ps;
|
||||||
|
offset += ps as u64;
|
||||||
|
}
|
||||||
|
if pos < self.data.len() {
|
||||||
|
interceptor.on_page_write(offset, &self.data[pos..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.flush_to_disk()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HDF5Read for FileWriter {
|
impl HDF5Read for FileWriter {
|
||||||
@@ -281,6 +305,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
# edgehdf5-migrate
|
# clawhdf5-migrate
|
||||||
|
|
||||||
[](https://crates.io/crates/edgehdf5-migrate)
|
[](https://crates.io/crates/clawhdf5-migrate)
|
||||||
[](https://docs.rs/edgehdf5-migrate)
|
[](https://docs.rs/clawhdf5-migrate)
|
||||||
|
|
||||||
CLI tool to migrate SQLite agent memory databases to HDF5 format.
|
CLI tool to migrate SQLite agent memory databases to HDF5 format.
|
||||||
|
|
||||||
Converts existing SQLite-based agent memory stores (embeddings, text chunks, metadata) into the HDF5 format used by [edgehdf5-memory](https://crates.io/crates/edgehdf5-memory).
|
Converts existing SQLite-based agent memory stores (embeddings, text chunks, metadata) into the HDF5 format used by [clawhdf5-agent](https://crates.io/crates/clawhdf5-agent).
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo install edgehdf5-migrate
|
cargo install clawhdf5-migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
edgehdf5-migrate --input agent.db --output agent.h5
|
clawhdf5-migrate --input agent.db --output agent.h5
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
//! Read a migration HDF5 file back into the in-memory data model.
|
||||||
|
//!
|
||||||
|
//! Used to verify migrated content (real validation) and to merge new rows into
|
||||||
|
//! an existing output (incremental migration). Mirrors the layout produced by
|
||||||
|
//! [`crate::hdf5_writer`].
|
||||||
|
|
||||||
|
use clawhdf5::reader::{File, Group};
|
||||||
|
use clawhdf5_format::type_builders::AttrValue;
|
||||||
|
|
||||||
|
use crate::sqlite_reader::{Entity, MemoryChunk, Relation, Session, SqliteData};
|
||||||
|
|
||||||
|
type BoxErr = Box<dyn std::error::Error>;
|
||||||
|
|
||||||
|
fn read_strings(group: &Group<'_>, name: &str) -> Result<Vec<String>, BoxErr> {
|
||||||
|
Ok(group.dataset(name)?.read_string()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_i64s(group: &Group<'_>, name: &str) -> Result<Vec<i64>, BoxErr> {
|
||||||
|
Ok(group.dataset(name)?.read_i64()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_f64s(group: &Group<'_>, name: &str) -> Result<Vec<f64>, BoxErr> {
|
||||||
|
Ok(group.dataset(name)?.read_f64()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the embeddings dataset as a flat `Vec<f32>` of `n * dim` values,
|
||||||
|
/// handling both f32 and (lossy) f16 storage.
|
||||||
|
fn read_embeddings_flat(group: &Group<'_>) -> Result<Vec<f32>, BoxErr> {
|
||||||
|
Ok(group.dataset("embeddings")?.read_f32()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a migration HDF5 file into a [`SqliteData`].
|
||||||
|
pub fn read_hdf5(path: &str) -> Result<SqliteData, BoxErr> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
|
||||||
|
let embedding_dim = match file.root().attrs()?.get("embedding_dim") {
|
||||||
|
Some(AttrValue::I64(d)) => *d as usize,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let chunks = read_chunks(&file, embedding_dim)?;
|
||||||
|
let sessions = read_sessions(&file)?;
|
||||||
|
let entities = read_entities(&file)?;
|
||||||
|
let relations = read_relations(&file)?;
|
||||||
|
|
||||||
|
Ok(SqliteData {
|
||||||
|
chunks,
|
||||||
|
sessions,
|
||||||
|
entities,
|
||||||
|
relations,
|
||||||
|
embedding_dim,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_chunks(file: &File, dim: usize) -> Result<Vec<MemoryChunk>, BoxErr> {
|
||||||
|
let g = file.group("chunks")?;
|
||||||
|
let count = group_count(&g)?;
|
||||||
|
if count == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let ids = read_i64s(&g, "id")?;
|
||||||
|
let texts = read_strings(&g, "text")?;
|
||||||
|
let channels = read_strings(&g, "source_channel")?;
|
||||||
|
let timestamps = read_f64s(&g, "timestamp")?;
|
||||||
|
let session_ids = read_strings(&g, "session_id")?;
|
||||||
|
let tags = read_strings(&g, "tags")?;
|
||||||
|
let deleted = g.dataset("deleted")?.read_i32()?;
|
||||||
|
let emb_flat = read_embeddings_flat(&g)?;
|
||||||
|
let dim = dim.max(1);
|
||||||
|
|
||||||
|
let mut chunks = Vec::with_capacity(ids.len());
|
||||||
|
for (i, &id) in ids.iter().enumerate() {
|
||||||
|
let embedding = emb_flat
|
||||||
|
.get(i * dim..(i + 1) * dim)
|
||||||
|
.map(|s| s.to_vec())
|
||||||
|
.unwrap_or_default();
|
||||||
|
chunks.push(MemoryChunk {
|
||||||
|
id,
|
||||||
|
chunk: texts.get(i).cloned().unwrap_or_default(),
|
||||||
|
embedding,
|
||||||
|
source_channel: channels.get(i).cloned().unwrap_or_default(),
|
||||||
|
timestamp: timestamps.get(i).copied().unwrap_or(0.0),
|
||||||
|
session_id: session_ids.get(i).cloned().unwrap_or_default(),
|
||||||
|
tags: tags.get(i).cloned().unwrap_or_default(),
|
||||||
|
deleted: deleted.get(i).copied().unwrap_or(0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(chunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_sessions(file: &File) -> Result<Vec<Session>, BoxErr> {
|
||||||
|
let g = file.group("sessions")?;
|
||||||
|
if group_count(&g)? == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let ids = read_strings(&g, "id")?;
|
||||||
|
let starts = read_i64s(&g, "start_idx")?;
|
||||||
|
let ends = read_i64s(&g, "end_idx")?;
|
||||||
|
let channels = read_strings(&g, "channel")?;
|
||||||
|
let timestamps = read_f64s(&g, "timestamp")?;
|
||||||
|
let summaries = read_strings(&g, "summary")?;
|
||||||
|
Ok((0..ids.len())
|
||||||
|
.map(|i| Session {
|
||||||
|
id: ids[i].clone(),
|
||||||
|
start_idx: starts.get(i).copied().unwrap_or(0),
|
||||||
|
end_idx: ends.get(i).copied().unwrap_or(0),
|
||||||
|
channel: channels.get(i).cloned().unwrap_or_default(),
|
||||||
|
timestamp: timestamps.get(i).copied().unwrap_or(0.0),
|
||||||
|
summary: summaries.get(i).cloned().unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_entities(file: &File) -> Result<Vec<Entity>, BoxErr> {
|
||||||
|
let g = file.group("entities")?;
|
||||||
|
if group_count(&g)? == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let ids = read_i64s(&g, "id")?;
|
||||||
|
let names = read_strings(&g, "name")?;
|
||||||
|
let types = read_strings(&g, "type")?;
|
||||||
|
let emb_idxs = read_i64s(&g, "embedding_idx")?;
|
||||||
|
Ok((0..ids.len())
|
||||||
|
.map(|i| Entity {
|
||||||
|
id: ids[i],
|
||||||
|
name: names.get(i).cloned().unwrap_or_default(),
|
||||||
|
entity_type: types.get(i).cloned().unwrap_or_default(),
|
||||||
|
embedding_idx: emb_idxs.get(i).copied().unwrap_or(-1),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_relations(file: &File) -> Result<Vec<Relation>, BoxErr> {
|
||||||
|
let g = file.group("relations")?;
|
||||||
|
if group_count(&g)? == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let srcs = read_i64s(&g, "src")?;
|
||||||
|
let tgts = read_i64s(&g, "tgt")?;
|
||||||
|
let rels = read_strings(&g, "relation")?;
|
||||||
|
let weights = read_f64s(&g, "weight")?;
|
||||||
|
let timestamps = read_f64s(&g, "timestamp")?;
|
||||||
|
Ok((0..srcs.len())
|
||||||
|
.map(|i| Relation {
|
||||||
|
src: srcs[i],
|
||||||
|
tgt: tgts.get(i).copied().unwrap_or(0),
|
||||||
|
relation: rels.get(i).cloned().unwrap_or_default(),
|
||||||
|
weight: weights.get(i).copied().unwrap_or(1.0),
|
||||||
|
timestamp: timestamps.get(i).copied().unwrap_or(0.0),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_count(group: &Group<'_>) -> Result<u64, BoxErr> {
|
||||||
|
match group.attrs()?.get("count") {
|
||||||
|
Some(AttrValue::I64(n)) => Ok(*n as u64),
|
||||||
|
_ => Ok(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
mod hdf5_reader;
|
||||||
mod hdf5_writer;
|
mod hdf5_writer;
|
||||||
mod sqlite_reader;
|
mod sqlite_reader;
|
||||||
mod validate;
|
mod validate;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
|
use sqlite_reader::SchemaConfig;
|
||||||
|
|
||||||
/// Migrate ZeroClaw agent memory from SQLite to HDF5 format.
|
/// Migrate ZeroClaw agent memory from SQLite to HDF5 format.
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(name = "clawhdf5-migrate", version, about)]
|
#[command(name = "clawhdf5-migrate", version, about)]
|
||||||
@@ -48,45 +51,126 @@ struct Cli {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
|
|
||||||
|
/// Content-check every migrated row (default: a representative sample)
|
||||||
|
#[arg(long)]
|
||||||
|
validate_full: bool,
|
||||||
|
|
||||||
|
/// Append only rows newer than the existing output (by chunk id), merging
|
||||||
|
/// into the file at --hdf5 if it exists
|
||||||
|
#[arg(long)]
|
||||||
|
incremental: bool,
|
||||||
|
|
||||||
|
/// Override the SQLite table name for memory chunks
|
||||||
|
#[arg(long)]
|
||||||
|
chunks_table: Option<String>,
|
||||||
|
|
||||||
|
/// Override the SQLite table name for sessions
|
||||||
|
#[arg(long)]
|
||||||
|
sessions_table: Option<String>,
|
||||||
|
|
||||||
|
/// Override the SQLite table name for entities
|
||||||
|
#[arg(long)]
|
||||||
|
entities_table: Option<String>,
|
||||||
|
|
||||||
|
/// Override the SQLite table name for relations
|
||||||
|
#[arg(long)]
|
||||||
|
relations_table: Option<String>,
|
||||||
|
|
||||||
/// Print progress
|
/// Print progress
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the schema config from CLI table-name overrides (defaults otherwise).
|
||||||
|
fn schema_from_cli(cli: &Cli) -> SchemaConfig {
|
||||||
|
let mut c = SchemaConfig::default();
|
||||||
|
if let Some(t) = &cli.chunks_table {
|
||||||
|
c.chunks.table = t.clone();
|
||||||
|
}
|
||||||
|
if let Some(t) = &cli.sessions_table {
|
||||||
|
c.sessions.table = t.clone();
|
||||||
|
}
|
||||||
|
if let Some(t) = &cli.entities_table {
|
||||||
|
c.entities.table = t.clone();
|
||||||
|
}
|
||||||
|
if let Some(t) = &cli.relations_table {
|
||||||
|
c.relations.table = t.clone();
|
||||||
|
}
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
let schema = schema_from_cli(&cli);
|
||||||
|
|
||||||
|
// Dry run: a fast count-only pass that does not buffer the database.
|
||||||
|
if cli.dry_run {
|
||||||
|
let counts = sqlite_reader::read_counts(&cli.sqlite, cli.skip_deleted, &schema)?;
|
||||||
|
eprintln!("Dry run — no output file written.");
|
||||||
|
eprintln!(
|
||||||
|
"Would migrate: {} chunks, {} sessions, {} entities, {} relations",
|
||||||
|
counts.chunks, counts.sessions, counts.entities, counts.relations
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
if cli.verbose {
|
if cli.verbose {
|
||||||
eprintln!("Reading SQLite database: {}", cli.sqlite);
|
eprintln!("Reading SQLite database: {}", cli.sqlite);
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&cli.sqlite, cli.skip_deleted, cli.embedding_dim)?;
|
// Incremental: merge new rows into the existing output (if present).
|
||||||
|
let incremental_base = if cli.incremental && std::path::Path::new(&cli.hdf5).exists() {
|
||||||
|
Some(hdf5_reader::read_hdf5(&cli.hdf5)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let min_chunk_id = incremental_base
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| d.chunks.iter().map(|c| c.id).max().unwrap_or(0))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let dim_hint = cli
|
||||||
|
.embedding_dim
|
||||||
|
.or_else(|| incremental_base.as_ref().map(|d| d.embedding_dim));
|
||||||
|
|
||||||
|
let source = if min_chunk_id > 0 {
|
||||||
|
sqlite_reader::read_sqlite_filtered(
|
||||||
|
&cli.sqlite,
|
||||||
|
cli.skip_deleted,
|
||||||
|
dim_hint,
|
||||||
|
&schema,
|
||||||
|
min_chunk_id,
|
||||||
|
)?
|
||||||
|
} else {
|
||||||
|
sqlite_reader::read_sqlite(&cli.sqlite, cli.skip_deleted, dim_hint, &schema)?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the dataset to write: either the source alone, or the existing
|
||||||
|
// output plus the newly-read rows (metadata groups refreshed from source).
|
||||||
|
let data = match incremental_base {
|
||||||
|
Some(mut base) => {
|
||||||
|
let added = source.chunks.len();
|
||||||
|
base.chunks.extend(source.chunks);
|
||||||
|
base.sessions = source.sessions;
|
||||||
|
base.entities = source.entities;
|
||||||
|
base.relations = source.relations;
|
||||||
|
base.embedding_dim = source.embedding_dim.max(base.embedding_dim);
|
||||||
|
if cli.verbose {
|
||||||
|
eprintln!("Incremental: appended {added} new chunks (id > {min_chunk_id})");
|
||||||
|
}
|
||||||
|
base
|
||||||
|
}
|
||||||
|
None => source,
|
||||||
|
};
|
||||||
|
|
||||||
if cli.verbose {
|
if cli.verbose {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Read {} chunks, {} sessions, {} entities, {} relations",
|
"Migrating {} chunks, {} sessions, {} entities, {} relations (dim={})",
|
||||||
data.chunks.len(),
|
|
||||||
data.sessions.len(),
|
|
||||||
data.entities.len(),
|
|
||||||
data.relations.len()
|
|
||||||
);
|
|
||||||
eprintln!("Embedding dimension: {}", data.embedding_dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
if cli.dry_run {
|
|
||||||
eprintln!("Dry run — no output file written.");
|
|
||||||
eprintln!(
|
|
||||||
"Would migrate: {} chunks, {} sessions, {} entities, {} relations (dim={})",
|
|
||||||
data.chunks.len(),
|
data.chunks.len(),
|
||||||
data.sessions.len(),
|
data.sessions.len(),
|
||||||
data.entities.len(),
|
data.entities.len(),
|
||||||
data.relations.len(),
|
data.relations.len(),
|
||||||
data.embedding_dim
|
data.embedding_dim
|
||||||
);
|
);
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if cli.verbose {
|
|
||||||
eprintln!("Writing HDF5 file: {}", cli.hdf5);
|
eprintln!("Writing HDF5 file: {}", cli.hdf5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,25 +185,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
hdf5_writer::write_hdf5(&cli.hdf5, &data, &opts)?;
|
hdf5_writer::write_hdf5(&cli.hdf5, &data, &opts)?;
|
||||||
|
|
||||||
if cli.verbose {
|
if cli.verbose {
|
||||||
eprintln!("Validating output...");
|
eprintln!("Validating output (content check)...");
|
||||||
}
|
}
|
||||||
|
|
||||||
let summary = validate::validate_hdf5(
|
let summary = validate::validate_hdf5(&cli.hdf5, &data, cli.validate_full, cli.float16)?;
|
||||||
&cli.hdf5,
|
|
||||||
data.chunks.len(),
|
|
||||||
data.sessions.len(),
|
|
||||||
data.entities.len(),
|
|
||||||
data.relations.len(),
|
|
||||||
data.embedding_dim,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Migration complete: {} chunks, {} sessions, {} entities, {} relations (dim={})",
|
"Migration complete: {} chunks, {} sessions, {} entities, {} relations (dim={}); {} rows content-verified",
|
||||||
summary.chunks,
|
summary.chunks,
|
||||||
summary.sessions,
|
summary.sessions,
|
||||||
summary.entities,
|
summary.entities,
|
||||||
summary.relations,
|
summary.relations,
|
||||||
summary.embedding_dim
|
summary.embedding_dim,
|
||||||
|
summary.rows_checked,
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -231,7 +309,8 @@ mod tests {
|
|||||||
insert_relation(&conn, 1, 1, "self");
|
insert_relation(&conn, 1, 1, "self");
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
let opts = hdf5_writer::WriteOptions {
|
let opts = hdf5_writer::WriteOptions {
|
||||||
agent_id: "test-agent".into(),
|
agent_id: "test-agent".into(),
|
||||||
embedder: "test-embed".into(),
|
embedder: "test-embed".into(),
|
||||||
@@ -241,7 +320,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
||||||
|
|
||||||
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 2, 1, 1, 1, 8).unwrap();
|
let summary =
|
||||||
|
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
|
||||||
assert_eq!(summary.chunks, 2);
|
assert_eq!(summary.chunks, 2);
|
||||||
assert_eq!(summary.sessions, 1);
|
assert_eq!(summary.sessions, 1);
|
||||||
assert_eq!(summary.entities, 1);
|
assert_eq!(summary.entities, 1);
|
||||||
@@ -262,7 +342,8 @@ mod tests {
|
|||||||
insert_chunk(&conn, 3, "also active", &make_embedding(4, 3.0), 0);
|
insert_chunk(&conn, 3, "also active", &make_embedding(4, 3.0), 0);
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, true, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, true, None, &SchemaConfig::default()).unwrap();
|
||||||
assert_eq!(data.chunks.len(), 2);
|
assert_eq!(data.chunks.len(), 2);
|
||||||
|
|
||||||
let opts = hdf5_writer::WriteOptions {
|
let opts = hdf5_writer::WriteOptions {
|
||||||
@@ -274,7 +355,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
||||||
|
|
||||||
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 2, 0, 0, 0, 4).unwrap();
|
let summary =
|
||||||
|
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
|
||||||
assert_eq!(summary.chunks, 2);
|
assert_eq!(summary.chunks, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +371,8 @@ mod tests {
|
|||||||
insert_chunk(&conn, 2, "deleted", &make_embedding(4, 2.0), 1);
|
insert_chunk(&conn, 2, "deleted", &make_embedding(4, 2.0), 1);
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
assert_eq!(data.chunks.len(), 2);
|
assert_eq!(data.chunks.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +386,8 @@ mod tests {
|
|||||||
insert_chunk(&conn, 1, "test", &make_embedding(16, 0.5), 0);
|
insert_chunk(&conn, 1, "test", &make_embedding(16, 0.5), 0);
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
assert_eq!(data.embedding_dim, 16);
|
assert_eq!(data.embedding_dim, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +401,8 @@ mod tests {
|
|||||||
insert_chunk(&conn, 1, "test", &make_embedding(16, 0.5), 0);
|
insert_chunk(&conn, 1, "test", &make_embedding(16, 0.5), 0);
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, false, Some(8)).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, Some(8), &SchemaConfig::default()).unwrap();
|
||||||
assert_eq!(data.embedding_dim, 8);
|
assert_eq!(data.embedding_dim, 8);
|
||||||
// Embedding truncated to dim 8
|
// Embedding truncated to dim 8
|
||||||
assert_eq!(data.chunks[0].embedding.len(), 8);
|
assert_eq!(data.chunks[0].embedding.len(), 8);
|
||||||
@@ -335,7 +420,8 @@ mod tests {
|
|||||||
insert_chunk(&conn, 1, "test", &emb, 0);
|
insert_chunk(&conn, 1, "test", &emb, 0);
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
let opts = hdf5_writer::WriteOptions {
|
let opts = hdf5_writer::WriteOptions {
|
||||||
agent_id: "t".into(),
|
agent_id: "t".into(),
|
||||||
embedder: "t".into(),
|
embedder: "t".into(),
|
||||||
@@ -345,8 +431,9 @@ mod tests {
|
|||||||
};
|
};
|
||||||
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
||||||
|
|
||||||
// Verify file was created and is valid
|
// Content-validate with the float16 tolerance enabled.
|
||||||
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 1, 0, 0, 0, 4).unwrap();
|
let summary =
|
||||||
|
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, true, true).unwrap();
|
||||||
assert_eq!(summary.chunks, 1);
|
assert_eq!(summary.chunks, 1);
|
||||||
|
|
||||||
// Verify float16 values are within tolerance
|
// Verify float16 values are within tolerance
|
||||||
@@ -375,7 +462,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
|
|
||||||
let opts_compressed = hdf5_writer::WriteOptions {
|
let opts_compressed = hdf5_writer::WriteOptions {
|
||||||
agent_id: "t".into(),
|
agent_id: "t".into(),
|
||||||
@@ -415,7 +503,8 @@ mod tests {
|
|||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
// Simulate dry-run: read data but don't write
|
// Simulate dry-run: read data but don't write
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
assert_eq!(data.chunks.len(), 1);
|
assert_eq!(data.chunks.len(), 1);
|
||||||
assert!(!h5_path.exists());
|
assert!(!h5_path.exists());
|
||||||
}
|
}
|
||||||
@@ -427,7 +516,8 @@ mod tests {
|
|||||||
let db_path = create_test_db(&dir);
|
let db_path = create_test_db(&dir);
|
||||||
let h5_path = dir.path().join("out.h5");
|
let h5_path = dir.path().join("out.h5");
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
assert_eq!(data.chunks.len(), 0);
|
assert_eq!(data.chunks.len(), 0);
|
||||||
assert_eq!(data.sessions.len(), 0);
|
assert_eq!(data.sessions.len(), 0);
|
||||||
assert_eq!(data.entities.len(), 0);
|
assert_eq!(data.entities.len(), 0);
|
||||||
@@ -442,7 +532,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
||||||
|
|
||||||
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 0, 0, 0, 0, 0).unwrap();
|
let summary =
|
||||||
|
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
|
||||||
assert_eq!(summary.chunks, 0);
|
assert_eq!(summary.chunks, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,7 +556,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
assert_eq!(data.chunks.len(), 1000);
|
assert_eq!(data.chunks.len(), 1000);
|
||||||
|
|
||||||
let opts = hdf5_writer::WriteOptions {
|
let opts = hdf5_writer::WriteOptions {
|
||||||
@@ -478,7 +570,7 @@ mod tests {
|
|||||||
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
||||||
|
|
||||||
let summary =
|
let summary =
|
||||||
validate::validate_hdf5(h5_path.to_str().unwrap(), 1000, 0, 0, 0, 64).unwrap();
|
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
|
||||||
assert_eq!(summary.chunks, 1000);
|
assert_eq!(summary.chunks, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,7 +587,8 @@ mod tests {
|
|||||||
insert_session(&conn, "session-gamma", 21, 30);
|
insert_session(&conn, "session-gamma", 21, 30);
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
assert_eq!(data.sessions.len(), 3);
|
assert_eq!(data.sessions.len(), 3);
|
||||||
|
|
||||||
let opts = hdf5_writer::WriteOptions {
|
let opts = hdf5_writer::WriteOptions {
|
||||||
@@ -507,7 +600,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
||||||
|
|
||||||
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 0, 3, 0, 0, 0).unwrap();
|
let summary =
|
||||||
|
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
|
||||||
assert_eq!(summary.sessions, 3);
|
assert_eq!(summary.sessions, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,7 +621,8 @@ mod tests {
|
|||||||
insert_relation(&conn, 2, 3, "uses");
|
insert_relation(&conn, 2, 3, "uses");
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
assert_eq!(data.entities.len(), 3);
|
assert_eq!(data.entities.len(), 3);
|
||||||
assert_eq!(data.relations.len(), 3);
|
assert_eq!(data.relations.len(), 3);
|
||||||
|
|
||||||
@@ -540,7 +635,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
||||||
|
|
||||||
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 0, 0, 3, 3, 0).unwrap();
|
let summary =
|
||||||
|
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
|
||||||
assert_eq!(summary.entities, 3);
|
assert_eq!(summary.entities, 3);
|
||||||
assert_eq!(summary.relations, 3);
|
assert_eq!(summary.relations, 3);
|
||||||
}
|
}
|
||||||
@@ -556,7 +652,8 @@ mod tests {
|
|||||||
insert_chunk(&conn, 1, "test", &make_embedding(4, 1.0), 0);
|
insert_chunk(&conn, 1, "test", &make_embedding(4, 1.0), 0);
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
let opts = hdf5_writer::WriteOptions {
|
let opts = hdf5_writer::WriteOptions {
|
||||||
agent_id: "t".into(),
|
agent_id: "t".into(),
|
||||||
embedder: "t".into(),
|
embedder: "t".into(),
|
||||||
@@ -566,15 +663,15 @@ mod tests {
|
|||||||
};
|
};
|
||||||
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
||||||
|
|
||||||
// Expect 5 chunks but only 1 was written
|
// Validating against a source with an extra (unwritten) chunk must fail.
|
||||||
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), 5, 0, 0, 0, 4);
|
let mut bigger =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
|
let mut extra = bigger.chunks[0].clone();
|
||||||
|
extra.id = 999;
|
||||||
|
bigger.chunks.push(extra);
|
||||||
|
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), &bigger, false, false);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(
|
assert!(result.unwrap_err().to_string().contains("count mismatch"));
|
||||||
result
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string()
|
|
||||||
.contains("Chunk count mismatch")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Test 14: Metadata attributes are stored ----------
|
// ---------- Test 14: Metadata attributes are stored ----------
|
||||||
@@ -588,7 +685,8 @@ mod tests {
|
|||||||
insert_chunk(&conn, 1, "test", &make_embedding(8, 1.0), 0);
|
insert_chunk(&conn, 1, "test", &make_embedding(8, 1.0), 0);
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
let opts = hdf5_writer::WriteOptions {
|
let opts = hdf5_writer::WriteOptions {
|
||||||
agent_id: "my-agent-42".into(),
|
agent_id: "my-agent-42".into(),
|
||||||
embedder: "openai-ada".into(),
|
embedder: "openai-ada".into(),
|
||||||
@@ -634,7 +732,8 @@ mod tests {
|
|||||||
insert_chunk(&conn, 1, "test", &emb, 0);
|
insert_chunk(&conn, 1, "test", &emb, 0);
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
let opts = hdf5_writer::WriteOptions {
|
let opts = hdf5_writer::WriteOptions {
|
||||||
agent_id: "t".into(),
|
agent_id: "t".into(),
|
||||||
embedder: "t".into(),
|
embedder: "t".into(),
|
||||||
@@ -680,7 +779,8 @@ mod tests {
|
|||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
// Skip deleted
|
// Skip deleted
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, true, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, true, None, &SchemaConfig::default()).unwrap();
|
||||||
assert_eq!(data.chunks.len(), 4); // chunk 3 is deleted
|
assert_eq!(data.chunks.len(), 4); // chunk 3 is deleted
|
||||||
|
|
||||||
let opts = hdf5_writer::WriteOptions {
|
let opts = hdf5_writer::WriteOptions {
|
||||||
@@ -692,7 +792,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
||||||
|
|
||||||
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 4, 2, 2, 1, 16).unwrap();
|
let summary =
|
||||||
|
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
|
||||||
assert_eq!(summary.chunks, 4);
|
assert_eq!(summary.chunks, 4);
|
||||||
assert_eq!(summary.sessions, 2);
|
assert_eq!(summary.sessions, 2);
|
||||||
assert_eq!(summary.entities, 2);
|
assert_eq!(summary.entities, 2);
|
||||||
@@ -711,7 +812,8 @@ mod tests {
|
|||||||
insert_session(&conn, "s1", 0, 10);
|
insert_session(&conn, "s1", 0, 10);
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
let opts = hdf5_writer::WriteOptions {
|
let opts = hdf5_writer::WriteOptions {
|
||||||
agent_id: "t".into(),
|
agent_id: "t".into(),
|
||||||
embedder: "t".into(),
|
embedder: "t".into(),
|
||||||
@@ -721,13 +823,130 @@ mod tests {
|
|||||||
};
|
};
|
||||||
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
||||||
|
|
||||||
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), 0, 99, 0, 0, 0);
|
// Validating against a source whose session content differs must fail.
|
||||||
|
let mut tampered =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
|
tampered.sessions[0].summary = "DIFFERENT".into();
|
||||||
|
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), &tampered, false, false);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(
|
assert!(result.unwrap_err().to_string().contains("session"));
|
||||||
result
|
}
|
||||||
.unwrap_err()
|
|
||||||
.to_string()
|
// ---------- Real content validation catches corrupt embeddings ----------
|
||||||
.contains("Session count mismatch")
|
#[test]
|
||||||
);
|
fn test_content_validation_catches_embedding_corruption() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let db_path = create_test_db(&dir);
|
||||||
|
let h5_path = dir.path().join("out.h5");
|
||||||
|
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
insert_chunk(&conn, 1, "hello", &make_embedding(8, 1.0), 0);
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
let data =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
|
let opts = hdf5_writer::WriteOptions {
|
||||||
|
agent_id: "t".into(),
|
||||||
|
embedder: "t".into(),
|
||||||
|
compression: false,
|
||||||
|
compression_level: 4,
|
||||||
|
float16: false,
|
||||||
|
};
|
||||||
|
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
||||||
|
|
||||||
|
// A source whose embedding differs (but counts match) must fail validation.
|
||||||
|
let mut tampered =
|
||||||
|
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
|
||||||
|
tampered.chunks[0].embedding[3] += 9.0;
|
||||||
|
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), &tampered, true, false);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("embedding"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Configurable schema: custom table names ----------
|
||||||
|
#[test]
|
||||||
|
fn test_configurable_table_names() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let db_path = dir.path().join("custom.db");
|
||||||
|
let path_str = db_path.to_str().unwrap().to_string();
|
||||||
|
let conn = Connection::open(&path_str).unwrap();
|
||||||
|
// Chunks live in a differently-named table; the others use defaults.
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE my_chunks (
|
||||||
|
id INTEGER PRIMARY KEY, chunk TEXT, embedding BLOB,
|
||||||
|
source_channel TEXT, timestamp REAL, session_id TEXT, tags TEXT, deleted INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE sessions (id TEXT, start_idx INTEGER, end_idx INTEGER, channel TEXT, timestamp REAL, summary TEXT);
|
||||||
|
CREATE TABLE entities (id INTEGER, name TEXT, type TEXT, embedding_idx INTEGER);
|
||||||
|
CREATE TABLE relations (src INTEGER, tgt INTEGER, relation TEXT, weight REAL, timestamp REAL);",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let blob: Vec<u8> = make_embedding(4, 1.0)
|
||||||
|
.iter()
|
||||||
|
.flat_map(|v| v.to_le_bytes())
|
||||||
|
.collect();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO my_chunks VALUES (1, 'hi', ?1, 'api', 1.0, 's', '', 0)",
|
||||||
|
rusqlite::params![blob],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
let mut schema = SchemaConfig::default();
|
||||||
|
schema.chunks.table = "my_chunks".into();
|
||||||
|
let data = sqlite_reader::read_sqlite(&path_str, false, None, &schema).unwrap();
|
||||||
|
assert_eq!(data.chunks.len(), 1);
|
||||||
|
assert_eq!(data.chunks[0].chunk, "hi");
|
||||||
|
assert_eq!(data.embedding_dim, 4);
|
||||||
|
|
||||||
|
// Counts pass should also honor the custom table name.
|
||||||
|
let counts = sqlite_reader::read_counts(&path_str, false, &schema).unwrap();
|
||||||
|
assert_eq!(counts.chunks, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Incremental migration appends only new rows ----------
|
||||||
|
#[test]
|
||||||
|
fn test_incremental_migration() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let db_path = create_test_db(&dir);
|
||||||
|
let h5_path = dir.path().join("out.h5");
|
||||||
|
let cfg = SchemaConfig::default();
|
||||||
|
let opts = hdf5_writer::WriteOptions {
|
||||||
|
agent_id: "t".into(),
|
||||||
|
embedder: "t".into(),
|
||||||
|
compression: false,
|
||||||
|
compression_level: 4,
|
||||||
|
float16: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// First migration: 2 chunks.
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
insert_chunk(&conn, 1, "one", &make_embedding(4, 1.0), 0);
|
||||||
|
insert_chunk(&conn, 2, "two", &make_embedding(4, 2.0), 0);
|
||||||
|
drop(conn);
|
||||||
|
let data = sqlite_reader::read_sqlite(&db_path, false, None, &cfg).unwrap();
|
||||||
|
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
|
||||||
|
|
||||||
|
// Add two more rows, then migrate incrementally.
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
insert_chunk(&conn, 3, "three", &make_embedding(4, 3.0), 0);
|
||||||
|
insert_chunk(&conn, 4, "four", &make_embedding(4, 4.0), 0);
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
let base = hdf5_reader::read_hdf5(h5_path.to_str().unwrap()).unwrap();
|
||||||
|
let max_id = base.chunks.iter().map(|c| c.id).max().unwrap_or(0);
|
||||||
|
assert_eq!(max_id, 2);
|
||||||
|
let new =
|
||||||
|
sqlite_reader::read_sqlite_filtered(&db_path, false, Some(4), &cfg, max_id).unwrap();
|
||||||
|
assert_eq!(new.chunks.len(), 2); // only id 3 and 4
|
||||||
|
|
||||||
|
let mut merged = base;
|
||||||
|
merged.chunks.extend(new.chunks);
|
||||||
|
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &merged, &opts).unwrap();
|
||||||
|
|
||||||
|
let final_data = hdf5_reader::read_hdf5(h5_path.to_str().unwrap()).unwrap();
|
||||||
|
assert_eq!(final_data.chunks.len(), 4);
|
||||||
|
let texts: Vec<&str> = final_data.chunks.iter().map(|c| c.chunk.as_str()).collect();
|
||||||
|
assert_eq!(texts, vec!["one", "two", "three", "four"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,122 @@ pub struct SqliteData {
|
|||||||
pub embedding_dim: usize,
|
pub embedding_dim: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A table name plus the ordered column names the reader maps by position.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TableSchema {
|
||||||
|
pub table: String,
|
||||||
|
pub columns: Vec<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configurable mapping from a SQLite layout to the migration's data model.
|
||||||
|
///
|
||||||
|
/// Defaults to the ZeroClaw schema; the CLI can override the table names so the
|
||||||
|
/// tool can migrate databases whose tables are named differently. Column names
|
||||||
|
/// (and order) are part of the config too, so a library caller can remap them.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SchemaConfig {
|
||||||
|
pub chunks: TableSchema,
|
||||||
|
pub sessions: TableSchema,
|
||||||
|
pub entities: TableSchema,
|
||||||
|
pub relations: TableSchema,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SchemaConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
SchemaConfig {
|
||||||
|
chunks: TableSchema {
|
||||||
|
table: "memory_chunks".into(),
|
||||||
|
columns: vec![
|
||||||
|
"id",
|
||||||
|
"chunk",
|
||||||
|
"embedding",
|
||||||
|
"source_channel",
|
||||||
|
"timestamp",
|
||||||
|
"session_id",
|
||||||
|
"tags",
|
||||||
|
"deleted",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sessions: TableSchema {
|
||||||
|
table: "sessions".into(),
|
||||||
|
columns: vec![
|
||||||
|
"id",
|
||||||
|
"start_idx",
|
||||||
|
"end_idx",
|
||||||
|
"channel",
|
||||||
|
"timestamp",
|
||||||
|
"summary",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
entities: TableSchema {
|
||||||
|
table: "entities".into(),
|
||||||
|
columns: vec!["id", "name", "type", "embedding_idx"],
|
||||||
|
},
|
||||||
|
relations: TableSchema {
|
||||||
|
table: "relations".into(),
|
||||||
|
columns: vec!["src", "tgt", "relation", "weight", "timestamp"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TableSchema {
|
||||||
|
fn select(&self, where_clause: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT {} FROM {}{}",
|
||||||
|
self.columns.join(", "),
|
||||||
|
self.table,
|
||||||
|
where_clause
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row counts for each table — a fast pass that does not load row contents.
|
||||||
|
/// Used for `--dry-run` and progress without buffering the whole database.
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct RowCounts {
|
||||||
|
pub chunks: u64,
|
||||||
|
pub sessions: u64,
|
||||||
|
pub entities: u64,
|
||||||
|
pub relations: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_rows(conn: &Connection, table: &str, where_clause: &str) -> SqlResult<u64> {
|
||||||
|
conn.query_row(
|
||||||
|
&format!("SELECT COUNT(*) FROM {table}{where_clause}"),
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count rows in each table without reading their contents.
|
||||||
|
pub fn read_counts(
|
||||||
|
path: &str,
|
||||||
|
skip_deleted: bool,
|
||||||
|
config: &SchemaConfig,
|
||||||
|
) -> Result<RowCounts, Box<dyn std::error::Error>> {
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
let deleted_col = config.chunks.columns.get(7).copied().unwrap_or("deleted");
|
||||||
|
let chunk_where = if skip_deleted {
|
||||||
|
format!(" WHERE {deleted_col} = 0")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
Ok(RowCounts {
|
||||||
|
chunks: count_rows(&conn, &config.chunks.table, &chunk_where)?,
|
||||||
|
sessions: count_rows(&conn, &config.sessions.table, "")?,
|
||||||
|
entities: count_rows(&conn, &config.entities.table, "")?,
|
||||||
|
relations: count_rows(&conn, &config.relations.table, "")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Auto-detect embedding dimension from the first chunk's BLOB size.
|
/// Auto-detect embedding dimension from the first chunk's BLOB size.
|
||||||
fn detect_embedding_dim(conn: &Connection) -> SqlResult<Option<usize>> {
|
fn detect_embedding_dim(conn: &Connection, config: &SchemaConfig) -> SqlResult<Option<usize>> {
|
||||||
let mut stmt = conn.prepare("SELECT embedding FROM memory_chunks LIMIT 1")?;
|
let emb_col = config.chunks.columns.get(2).copied().unwrap_or("embedding");
|
||||||
|
let mut stmt = conn.prepare(&format!(
|
||||||
|
"SELECT {emb_col} FROM {} LIMIT 1",
|
||||||
|
config.chunks.table
|
||||||
|
))?;
|
||||||
let mut rows = stmt.query([])?;
|
let mut rows = stmt.query([])?;
|
||||||
if let Some(row) = rows.next()? {
|
if let Some(row) = rows.next()? {
|
||||||
let blob: Vec<u8> = row.get(0)?;
|
let blob: Vec<u8> = row.get(0)?;
|
||||||
@@ -80,18 +193,31 @@ pub fn read_sqlite(
|
|||||||
path: &str,
|
path: &str,
|
||||||
skip_deleted: bool,
|
skip_deleted: bool,
|
||||||
embedding_dim: Option<usize>,
|
embedding_dim: Option<usize>,
|
||||||
|
config: &SchemaConfig,
|
||||||
|
) -> Result<SqliteData, Box<dyn std::error::Error>> {
|
||||||
|
read_sqlite_filtered(path, skip_deleted, embedding_dim, config, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`read_sqlite`] but only reads chunks whose id is greater than
|
||||||
|
/// `min_chunk_id` (0 = all). Used for incremental migration.
|
||||||
|
pub fn read_sqlite_filtered(
|
||||||
|
path: &str,
|
||||||
|
skip_deleted: bool,
|
||||||
|
embedding_dim: Option<usize>,
|
||||||
|
config: &SchemaConfig,
|
||||||
|
min_chunk_id: i64,
|
||||||
) -> Result<SqliteData, Box<dyn std::error::Error>> {
|
) -> Result<SqliteData, Box<dyn std::error::Error>> {
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
|
|
||||||
let dim = match embedding_dim {
|
let dim = match embedding_dim {
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => detect_embedding_dim(&conn)?.unwrap_or(0),
|
None => detect_embedding_dim(&conn, config)?.unwrap_or(0),
|
||||||
};
|
};
|
||||||
|
|
||||||
let chunks = read_chunks(&conn, skip_deleted, dim)?;
|
let chunks = read_chunks(&conn, skip_deleted, dim, config, min_chunk_id)?;
|
||||||
let sessions = read_sessions(&conn)?;
|
let sessions = read_sessions(&conn, config)?;
|
||||||
let entities = read_entities(&conn)?;
|
let entities = read_entities(&conn, config)?;
|
||||||
let relations = read_relations(&conn)?;
|
let relations = read_relations(&conn, config)?;
|
||||||
|
|
||||||
Ok(SqliteData {
|
Ok(SqliteData {
|
||||||
chunks,
|
chunks,
|
||||||
@@ -106,16 +232,26 @@ fn read_chunks(
|
|||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
skip_deleted: bool,
|
skip_deleted: bool,
|
||||||
expected_dim: usize,
|
expected_dim: usize,
|
||||||
|
config: &SchemaConfig,
|
||||||
|
min_chunk_id: i64,
|
||||||
) -> SqlResult<Vec<MemoryChunk>> {
|
) -> SqlResult<Vec<MemoryChunk>> {
|
||||||
let sql = if skip_deleted {
|
let id_col = config.chunks.columns.first().copied().unwrap_or("id");
|
||||||
"SELECT id, chunk, embedding, source_channel, timestamp, session_id, tags, deleted \
|
let deleted_col = config.chunks.columns.get(7).copied().unwrap_or("deleted");
|
||||||
FROM memory_chunks WHERE deleted = 0"
|
let mut conds = Vec::new();
|
||||||
|
if skip_deleted {
|
||||||
|
conds.push(format!("{deleted_col} = 0"));
|
||||||
|
}
|
||||||
|
if min_chunk_id > 0 {
|
||||||
|
conds.push(format!("{id_col} > {min_chunk_id}"));
|
||||||
|
}
|
||||||
|
let where_clause = if conds.is_empty() {
|
||||||
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
"SELECT id, chunk, embedding, source_channel, timestamp, session_id, tags, deleted \
|
format!(" WHERE {}", conds.join(" AND "))
|
||||||
FROM memory_chunks"
|
|
||||||
};
|
};
|
||||||
|
let sql = config.chunks.select(&where_clause);
|
||||||
|
|
||||||
let mut stmt = conn.prepare(sql)?;
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
let blob: Vec<u8> = row.get(2)?;
|
let blob: Vec<u8> = row.get(2)?;
|
||||||
let mut embedding = blob_to_f32(&blob);
|
let mut embedding = blob_to_f32(&blob);
|
||||||
@@ -140,9 +276,8 @@ fn read_chunks(
|
|||||||
rows.collect()
|
rows.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_sessions(conn: &Connection) -> SqlResult<Vec<Session>> {
|
fn read_sessions(conn: &Connection, config: &SchemaConfig) -> SqlResult<Vec<Session>> {
|
||||||
let mut stmt =
|
let mut stmt = conn.prepare(&config.sessions.select(""))?;
|
||||||
conn.prepare("SELECT id, start_idx, end_idx, channel, timestamp, summary FROM sessions")?;
|
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok(Session {
|
Ok(Session {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
@@ -156,8 +291,8 @@ fn read_sessions(conn: &Connection) -> SqlResult<Vec<Session>> {
|
|||||||
rows.collect()
|
rows.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_entities(conn: &Connection) -> SqlResult<Vec<Entity>> {
|
fn read_entities(conn: &Connection, config: &SchemaConfig) -> SqlResult<Vec<Entity>> {
|
||||||
let mut stmt = conn.prepare("SELECT id, name, type, embedding_idx FROM entities")?;
|
let mut stmt = conn.prepare(&config.entities.select(""))?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok(Entity {
|
Ok(Entity {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
@@ -169,8 +304,8 @@ fn read_entities(conn: &Connection) -> SqlResult<Vec<Entity>> {
|
|||||||
rows.collect()
|
rows.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_relations(conn: &Connection) -> SqlResult<Vec<Relation>> {
|
fn read_relations(conn: &Connection, config: &SchemaConfig) -> SqlResult<Vec<Relation>> {
|
||||||
let mut stmt = conn.prepare("SELECT src, tgt, relation, weight, timestamp FROM relations")?;
|
let mut stmt = conn.prepare(&config.relations.select(""))?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok(Relation {
|
Ok(Relation {
|
||||||
src: row.get(0)?,
|
src: row.get(0)?,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use clawhdf5::reader::File;
|
use crate::hdf5_reader::read_hdf5;
|
||||||
use clawhdf5_format::type_builders::AttrValue;
|
use crate::sqlite_reader::SqliteData;
|
||||||
|
|
||||||
|
type BoxErr = Box<dyn std::error::Error>;
|
||||||
|
|
||||||
/// Summary of a migration validation.
|
/// Summary of a migration validation.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -9,119 +11,153 @@ pub struct ValidationSummary {
|
|||||||
pub entities: u64,
|
pub entities: u64,
|
||||||
pub relations: u64,
|
pub relations: u64,
|
||||||
pub embedding_dim: u64,
|
pub embedding_dim: u64,
|
||||||
|
/// Number of rows whose full content was compared against the source.
|
||||||
|
pub rows_checked: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate an HDF5 file written by the migration tool.
|
/// Validate a migrated HDF5 file against the source data.
|
||||||
///
|
///
|
||||||
/// Checks that row counts and embedding dimensions match expectations.
|
/// Reads the written file back and compares actual content — chunk text,
|
||||||
|
/// embeddings, and every session/entity/relation field — to the source, not
|
||||||
|
/// just the row counts. When `full` is false a representative sample of chunk
|
||||||
|
/// rows is content-checked (counts and all other groups are always checked in
|
||||||
|
/// full); when `full` is true every chunk row is compared too. `float16` widens
|
||||||
|
/// the embedding tolerance to allow for half-precision quantization.
|
||||||
pub fn validate_hdf5(
|
pub fn validate_hdf5(
|
||||||
path: &str,
|
path: &str,
|
||||||
expected_chunks: usize,
|
source: &SqliteData,
|
||||||
expected_sessions: usize,
|
full: bool,
|
||||||
expected_entities: usize,
|
float16: bool,
|
||||||
expected_relations: usize,
|
) -> Result<ValidationSummary, BoxErr> {
|
||||||
expected_dim: usize,
|
let got = read_hdf5(path)?;
|
||||||
) -> Result<ValidationSummary, Box<dyn std::error::Error>> {
|
|
||||||
let file = File::open(path)?;
|
|
||||||
let root = file.root();
|
|
||||||
|
|
||||||
// Read root attributes
|
// ---- Counts ----
|
||||||
let attrs = root.attrs()?;
|
check_count("chunk", got.chunks.len(), source.chunks.len())?;
|
||||||
let stored_dim = match attrs.get("embedding_dim") {
|
check_count("session", got.sessions.len(), source.sessions.len())?;
|
||||||
Some(AttrValue::I64(d)) => *d as u64,
|
check_count("entity", got.entities.len(), source.entities.len())?;
|
||||||
_ => 0,
|
check_count("relation", got.relations.len(), source.relations.len())?;
|
||||||
};
|
if got.embedding_dim != source.embedding_dim {
|
||||||
|
|
||||||
// Validate chunks group
|
|
||||||
let chunks_group = file.group("chunks")?;
|
|
||||||
let chunk_attrs = chunks_group.attrs()?;
|
|
||||||
let chunk_count = match chunk_attrs.get("count") {
|
|
||||||
Some(AttrValue::I64(n)) => *n as u64,
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
if chunk_count != expected_chunks as u64 {
|
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Chunk count mismatch: HDF5 has {}, expected {}",
|
"embedding_dim mismatch: HDF5 has {}, source has {}",
|
||||||
chunk_count, expected_chunks
|
got.embedding_dim, source.embedding_dim
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate embedding dimensions if chunks exist
|
// ---- Chunk content (sampled or full) ----
|
||||||
if chunk_count > 0 && expected_dim > 0 {
|
let (emb_abs, emb_rel) = if float16 { (1e-2, 1e-2) } else { (1e-4, 0.0) };
|
||||||
let emb_ds = chunks_group.dataset("embeddings")?;
|
let mut rows_checked = 0u64;
|
||||||
let shape = emb_ds.shape()?;
|
for i in sample_indices(source.chunks.len(), full) {
|
||||||
if shape.len() == 2 && shape[1] != expected_dim as u64 {
|
let (s, g) = (&source.chunks[i], &got.chunks[i]);
|
||||||
|
if s.id != g.id {
|
||||||
|
return Err(field_err("chunk", i, "id", s.id, g.id));
|
||||||
|
}
|
||||||
|
if s.chunk != g.chunk {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Embedding dim mismatch: HDF5 has {}, expected {}",
|
"chunk[{i}].text mismatch: source {:?}, HDF5 {:?}",
|
||||||
shape[1], expected_dim
|
truncate(&s.chunk),
|
||||||
|
truncate(&g.chunk)
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
if s.session_id != g.session_id || s.source_channel != g.source_channel || s.tags != g.tags
|
||||||
if stored_dim != expected_dim as u64 {
|
{
|
||||||
|
return Err(format!("chunk[{i}] string field mismatch").into());
|
||||||
|
}
|
||||||
|
if s.deleted != g.deleted {
|
||||||
|
return Err(field_err("chunk", i, "deleted", s.deleted, g.deleted));
|
||||||
|
}
|
||||||
|
if s.embedding.len() != g.embedding.len() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Embedding dim attr mismatch: HDF5 attr={}, expected {}",
|
"chunk[{i}] embedding length mismatch: {} vs {}",
|
||||||
stored_dim, expected_dim
|
s.embedding.len(),
|
||||||
|
g.embedding.len()
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
for (k, (&a, &b)) in s.embedding.iter().zip(g.embedding.iter()).enumerate() {
|
||||||
|
if (a - b).abs() > emb_abs + emb_rel * a.abs() {
|
||||||
|
return Err(
|
||||||
|
format!("chunk[{i}].embedding[{k}] mismatch: source {a}, HDF5 {b}").into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows_checked += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate sessions group
|
// ---- Other groups (always full — they are small) ----
|
||||||
let sessions_group = file.group("sessions")?;
|
for (i, (s, g)) in source.sessions.iter().zip(got.sessions.iter()).enumerate() {
|
||||||
let sess_attrs = sessions_group.attrs()?;
|
if s.id != g.id
|
||||||
let session_count = match sess_attrs.get("count") {
|
|| s.start_idx != g.start_idx
|
||||||
Some(AttrValue::I64(n)) => *n as u64,
|
|| s.end_idx != g.end_idx
|
||||||
_ => 0,
|
|| s.channel != g.channel
|
||||||
};
|
|| s.summary != g.summary
|
||||||
|
{
|
||||||
if session_count != expected_sessions as u64 {
|
return Err(format!("session[{i}] mismatch").into());
|
||||||
return Err(format!(
|
}
|
||||||
"Session count mismatch: HDF5 has {}, expected {}",
|
rows_checked += 1;
|
||||||
session_count, expected_sessions
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
}
|
||||||
|
for (i, (s, g)) in source.entities.iter().zip(got.entities.iter()).enumerate() {
|
||||||
// Validate entities group
|
if s.id != g.id
|
||||||
let entities_group = file.group("entities")?;
|
|| s.name != g.name
|
||||||
let ent_attrs = entities_group.attrs()?;
|
|| s.entity_type != g.entity_type
|
||||||
let entity_count = match ent_attrs.get("count") {
|
|| s.embedding_idx != g.embedding_idx
|
||||||
Some(AttrValue::I64(n)) => *n as u64,
|
{
|
||||||
_ => 0,
|
return Err(format!("entity[{i}] mismatch").into());
|
||||||
};
|
}
|
||||||
|
rows_checked += 1;
|
||||||
if entity_count != expected_entities as u64 {
|
|
||||||
return Err(format!(
|
|
||||||
"Entity count mismatch: HDF5 has {}, expected {}",
|
|
||||||
entity_count, expected_entities
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
}
|
||||||
|
for (i, (s, g)) in source
|
||||||
// Validate relations group
|
.relations
|
||||||
let relations_group = file.group("relations")?;
|
.iter()
|
||||||
let rel_attrs = relations_group.attrs()?;
|
.zip(got.relations.iter())
|
||||||
let relation_count = match rel_attrs.get("count") {
|
.enumerate()
|
||||||
Some(AttrValue::I64(n)) => *n as u64,
|
{
|
||||||
_ => 0,
|
if s.src != g.src || s.tgt != g.tgt || s.relation != g.relation {
|
||||||
};
|
return Err(format!("relation[{i}] mismatch").into());
|
||||||
|
}
|
||||||
if relation_count != expected_relations as u64 {
|
rows_checked += 1;
|
||||||
return Err(format!(
|
|
||||||
"Relation count mismatch: HDF5 has {}, expected {}",
|
|
||||||
relation_count, expected_relations
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ValidationSummary {
|
Ok(ValidationSummary {
|
||||||
chunks: chunk_count,
|
chunks: got.chunks.len() as u64,
|
||||||
sessions: session_count,
|
sessions: got.sessions.len() as u64,
|
||||||
entities: entity_count,
|
entities: got.entities.len() as u64,
|
||||||
relations: relation_count,
|
relations: got.relations.len() as u64,
|
||||||
embedding_dim: stored_dim,
|
embedding_dim: got.embedding_dim as u64,
|
||||||
|
rows_checked,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn check_count(kind: &str, got: usize, expected: usize) -> Result<(), BoxErr> {
|
||||||
|
if got != expected {
|
||||||
|
return Err(format!("{kind} count mismatch: HDF5 has {got}, source has {expected}").into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_err<T: std::fmt::Display>(kind: &str, i: usize, field: &str, s: T, g: T) -> BoxErr {
|
||||||
|
format!("{kind}[{i}].{field} mismatch: source {s}, HDF5 {g}").into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str) -> String {
|
||||||
|
if s.len() <= 40 {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}…", &s[..40])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indices of chunk rows to content-check. Full = all; otherwise a spread of
|
||||||
|
/// representative rows (first/last and evenly-spaced interior samples).
|
||||||
|
fn sample_indices(n: usize, full: bool) -> Vec<usize> {
|
||||||
|
if n == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if full || n <= 16 {
|
||||||
|
return (0..n).collect();
|
||||||
|
}
|
||||||
|
let mut idx: Vec<usize> = (0..16).map(|k| k * (n - 1) / 15).collect();
|
||||||
|
idx.dedup();
|
||||||
|
idx
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# rustyhdf5-netcdf4
|
# clawhdf5-netcdf4
|
||||||
|
|
||||||
[](https://crates.io/crates/rustyhdf5-netcdf4)
|
[](https://crates.io/crates/clawhdf5-netcdf4)
|
||||||
[](https://docs.rs/rustyhdf5-netcdf4)
|
[](https://docs.rs/clawhdf5-netcdf4)
|
||||||
|
|
||||||
NetCDF-4 read support built on rustyhdf5 — pure Rust, no C dependencies.
|
NetCDF-4 read support built on clawhdf5 — pure Rust, no C dependencies.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ NetCDF-4 read support built on rustyhdf5 — pure Rust, no C dependencies.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use rustyhdf5_netcdf4::NetCDF4File;
|
use clawhdf5_netcdf4::NetCDF4File;
|
||||||
|
|
||||||
let nc = NetCDF4File::open("climate.nc").unwrap();
|
let nc = NetCDF4File::open("climate.nc").unwrap();
|
||||||
let temp = nc.variable("temperature").unwrap();
|
let temp = nc.variable("temperature").unwrap();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# rustyhdf5-py
|
# clawhdf5-py
|
||||||
|
|
||||||
[](https://crates.io/crates/rustyhdf5-py)
|
[](https://crates.io/crates/clawhdf5-py)
|
||||||
[](https://docs.rs/rustyhdf5-py)
|
[](https://docs.rs/clawhdf5-py)
|
||||||
|
|
||||||
Python bindings for rustyhdf5 — a pure-Rust HDF5 library.
|
Python bindings for clawhdf5 — a pure-Rust HDF5 library.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -14,9 +14,9 @@ Python bindings for rustyhdf5 — a pure-Rust HDF5 library.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import rustyhdf5
|
import clawhdf5
|
||||||
|
|
||||||
with rustyhdf5.File('data.h5', 'r') as f:
|
with clawhdf5.File('data.h5', 'r') as f:
|
||||||
data = f['/dataset'][:]
|
data = f['/dataset'][:]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "clawhdf5-types"
|
|
||||||
version = "2.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
description = "HDF5 type system definitions for rustyhdf5"
|
|
||||||
license = "MIT"
|
|
||||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
|
||||||
readme = "README.md"
|
|
||||||
keywords = ["hdf5", "types", "science", "data"]
|
|
||||||
categories = ["data-structures", "science"]
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# rustyhdf5-types
|
|
||||||
|
|
||||||
[](https://crates.io/crates/rustyhdf5-types)
|
|
||||||
[](https://docs.rs/rustyhdf5-types)
|
|
||||||
|
|
||||||
HDF5 type system definitions for the rustyhdf5 ecosystem.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Complete HDF5 datatype representations (integer, float, string, compound, array, enum, etc.)
|
|
||||||
- Type conversion and validation utilities
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use rustyhdf5_types::HDF5Type;
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
//! HDF5 type system representation.
|
|
||||||
@@ -38,6 +38,7 @@ apple-compression = []
|
|||||||
zstd = ["clawhdf5-format/zstd"]
|
zstd = ["clawhdf5-format/zstd"]
|
||||||
blake3_hash = ["clawhdf5-format/blake3_hash"]
|
blake3_hash = ["clawhdf5-format/blake3_hash"]
|
||||||
lz4 = ["clawhdf5-format/lz4"]
|
lz4 = ["clawhdf5-format/lz4"]
|
||||||
|
pcodec = ["clawhdf5-format/pcodec"]
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
[package.metadata.docs.rs]
|
||||||
features = ["mmap"]
|
features = ["mmap"]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# rustyhdf5
|
# clawhdf5
|
||||||
|
|
||||||
[](https://crates.io/crates/rustyhdf5)
|
[](https://crates.io/crates/clawhdf5)
|
||||||
[](https://docs.rs/rustyhdf5)
|
[](https://docs.rs/clawhdf5)
|
||||||
|
|
||||||
Pure-Rust HDF5 reader/writer — no C dependencies.
|
Pure-Rust HDF5 reader/writer — no C dependencies.
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Pure-Rust HDF5 reader/writer — no C dependencies.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use rustyhdf5::File;
|
use clawhdf5::File;
|
||||||
|
|
||||||
let file = File::open("data.h5").unwrap();
|
let file = File::open("data.h5").unwrap();
|
||||||
let dataset = file.dataset("/group/data").unwrap();
|
let dataset = file.dataset("/group/data").unwrap();
|
||||||
|
|||||||
@@ -494,7 +494,9 @@ mod tests {
|
|||||||
let file = File::open(&path).unwrap();
|
let file = File::open(&path).unwrap();
|
||||||
let ds = file.dataset("data").unwrap();
|
let ds = file.dataset("data").unwrap();
|
||||||
|
|
||||||
if let Ok(slice) = ds.read_f32_zerocopy() { assert_eq!(slice, &original[..]) }
|
if let Ok(slice) = ds.read_f32_zerocopy() {
|
||||||
|
assert_eq!(slice, &original[..])
|
||||||
|
}
|
||||||
assert_eq!(ds.read_f32().unwrap(), original);
|
assert_eq!(ds.read_f32().unwrap(), original);
|
||||||
|
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ pub struct File {
|
|||||||
superblock: Superblock,
|
superblock: Superblock,
|
||||||
/// Per-file chunk cache shared across all dataset reads.
|
/// Per-file chunk cache shared across all dataset reads.
|
||||||
chunk_cache: ChunkCache,
|
chunk_cache: ChunkCache,
|
||||||
|
/// Directory the file was opened from, used to resolve external Virtual
|
||||||
|
/// Dataset source files relative to this file. `None` for in-memory files.
|
||||||
|
base_dir: Option<std::path::PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl File {
|
impl File {
|
||||||
@@ -74,6 +77,7 @@ impl File {
|
|||||||
/// When the `mmap` feature is enabled (default), this uses memory-mapped
|
/// When the `mmap` feature is enabled (default), this uses memory-mapped
|
||||||
/// I/O. Otherwise it reads the entire file into a `Vec<u8>`.
|
/// I/O. Otherwise it reads the entire file into a `Vec<u8>`.
|
||||||
pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
|
pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
|
||||||
|
let base_dir = path.as_ref().parent().map(|p| p.to_path_buf());
|
||||||
#[cfg(feature = "mmap")]
|
#[cfg(feature = "mmap")]
|
||||||
{
|
{
|
||||||
let reader = clawhdf5_io::MmapReader::open(path).map_err(Error::Io)?;
|
let reader = clawhdf5_io::MmapReader::open(path).map_err(Error::Io)?;
|
||||||
@@ -84,12 +88,15 @@ impl File {
|
|||||||
data: FileData::Mmap(reader),
|
data: FileData::Mmap(reader),
|
||||||
superblock,
|
superblock,
|
||||||
chunk_cache: ChunkCache::new(),
|
chunk_cache: ChunkCache::new(),
|
||||||
|
base_dir,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "mmap"))]
|
#[cfg(not(feature = "mmap"))]
|
||||||
{
|
{
|
||||||
let bytes = std::fs::read(path.as_ref()).map_err(Error::Io)?;
|
let bytes = std::fs::read(path.as_ref()).map_err(Error::Io)?;
|
||||||
Self::from_bytes(bytes)
|
let mut f = Self::from_bytes(bytes)?;
|
||||||
|
f.base_dir = base_dir;
|
||||||
|
Ok(f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +106,15 @@ impl File {
|
|||||||
/// undesirable (e.g. network filesystems, very small files, etc.).
|
/// undesirable (e.g. network filesystems, very small files, etc.).
|
||||||
pub fn open_buffered<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
|
pub fn open_buffered<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
|
||||||
let bytes = std::fs::read(path.as_ref()).map_err(Error::Io)?;
|
let bytes = std::fs::read(path.as_ref()).map_err(Error::Io)?;
|
||||||
Self::from_bytes(bytes)
|
let mut f = Self::from_bytes(bytes)?;
|
||||||
|
f.base_dir = path.as_ref().parent().map(|p| p.to_path_buf());
|
||||||
|
Ok(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open an HDF5 file from an in-memory byte vector.
|
/// Open an HDF5 file from an in-memory byte vector.
|
||||||
|
///
|
||||||
|
/// In-memory files have no directory, so external Virtual Dataset sources
|
||||||
|
/// cannot be resolved automatically (same-file VDS still works).
|
||||||
pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> {
|
pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> {
|
||||||
let sig_offset = signature::find_signature(&data)?;
|
let sig_offset = signature::find_signature(&data)?;
|
||||||
let superblock = Superblock::parse(&data, sig_offset)?;
|
let superblock = Superblock::parse(&data, sig_offset)?;
|
||||||
@@ -110,6 +122,7 @@ impl File {
|
|||||||
data: FileData::Owned(data),
|
data: FileData::Owned(data),
|
||||||
superblock,
|
superblock,
|
||||||
chunk_cache: ChunkCache::new(),
|
chunk_cache: ChunkCache::new(),
|
||||||
|
base_dir: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,6 +723,28 @@ impl<'f> Dataset<'f> {
|
|||||||
let ds = self.dataspace()?;
|
let ds = self.dataspace()?;
|
||||||
let dl = self.data_layout()?;
|
let dl = self.data_layout()?;
|
||||||
let pipeline = self.filter_pipeline();
|
let pipeline = self.filter_pipeline();
|
||||||
|
|
||||||
|
// Virtual datasets are assembled from source datasets; the per-file
|
||||||
|
// chunk cache does not apply. Route them through the resolver path so
|
||||||
|
// external sibling files resolve relative to this file's directory.
|
||||||
|
if matches!(dl, DataLayout::Virtual { .. }) {
|
||||||
|
let base_dir = self.file.base_dir.clone();
|
||||||
|
let resolver = move |name: &str| -> Option<Vec<u8>> {
|
||||||
|
let dir = base_dir.as_ref()?;
|
||||||
|
std::fs::read(dir.join(name)).ok()
|
||||||
|
};
|
||||||
|
return Ok(data_read::read_raw_data_full_with_resolver(
|
||||||
|
self.file.data.as_bytes(),
|
||||||
|
&dl,
|
||||||
|
&ds,
|
||||||
|
&dt,
|
||||||
|
pipeline.as_ref(),
|
||||||
|
self.file.offset_size(),
|
||||||
|
self.file.length_size(),
|
||||||
|
Some(&resolver),
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(data_read::read_raw_data_cached(
|
Ok(data_read::read_raw_data_cached(
|
||||||
self.file.data.as_bytes(),
|
self.file.data.as_bytes(),
|
||||||
&dl,
|
&dl,
|
||||||
|
|||||||
@@ -481,3 +481,77 @@ fn serde_json_minimal_parse(s: &str) -> Vec<f64> {
|
|||||||
.map(|v| v.trim().parse::<f64>().unwrap())
|
.map(|v| v.trim().parse::<f64>().unwrap())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// A_dense. Write a group with many links (dense storage) -> h5py reads all
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clawhdf5_writes_dense_group_h5py_reads() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("dense_group.h5");
|
||||||
|
let path_str = path.display().to_string();
|
||||||
|
|
||||||
|
// 20 links exceeds the compact threshold (8) -> dense fractal-heap storage.
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
let mut g = b.create_group("big");
|
||||||
|
for i in 0..20 {
|
||||||
|
g.create_dataset(&format!("dataset_{i:03}"))
|
||||||
|
.with_i32_data(&[i, i * 10]);
|
||||||
|
}
|
||||||
|
b.add_group(g.finish());
|
||||||
|
b.write(&path).unwrap();
|
||||||
|
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
import h5py
|
||||||
|
with h5py.File("{path_str}", "r") as f:
|
||||||
|
big = f["big"]
|
||||||
|
names = sorted(big.keys())
|
||||||
|
assert len(names) == 20, f"expected 20 links, got {{len(names)}}"
|
||||||
|
for i in range(20):
|
||||||
|
v = big[f"dataset_{{i:03}}"][()].tolist()
|
||||||
|
assert v == [i, i*10], f"link {{i}} = {{v}}"
|
||||||
|
print("OK")
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
let out = run_python_output(&script);
|
||||||
|
assert_eq!(out, "OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// A_multiblock. Write a multi-direct-block fractal heap -> h5py reads
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clawhdf5_writes_multiblock_heap_h5py_reads() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("multiblock.h5");
|
||||||
|
let path_str = path.display().to_string();
|
||||||
|
|
||||||
|
// ~1600 dense attributes overflow a single 64KiB fractal-heap direct block.
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
let mut g = b.create_group("g");
|
||||||
|
for i in 0..1600i64 {
|
||||||
|
g.set_attr(&format!("attribute_number_{i:05}"), AttrValue::I64(i * 2));
|
||||||
|
}
|
||||||
|
g.create_dataset("d").with_i32_data(&[1]);
|
||||||
|
b.add_group(g.finish());
|
||||||
|
b.write(&path).unwrap();
|
||||||
|
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
import h5py
|
||||||
|
with h5py.File("{path_str}", "r") as f:
|
||||||
|
a = f["g"].attrs
|
||||||
|
assert len(a) == 1600, f"expected 1600 attrs, got {{len(a)}}"
|
||||||
|
for i in (0, 1, 999, 1599):
|
||||||
|
v = int(a[f"attribute_number_{{i:05}}"])
|
||||||
|
assert v == i*2, f"attr {{i}} = {{v}}"
|
||||||
|
print("OK")
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
assert_eq!(run_python_output(&script), "OK");
|
||||||
|
}
|
||||||
|
|||||||
@@ -706,6 +706,97 @@ fn fletcher32_roundtrip() {
|
|||||||
// 15. Multiple groups with same-named datasets
|
// 15. Multiple groups with same-named datasets
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_chunked_datasets_share_file_cache() {
|
||||||
|
// The per-file ChunkCache is shared across datasets. Two chunked datasets
|
||||||
|
// of *different rank* must each read correctly: a 1-D dataset's chunk index
|
||||||
|
// (rank 1) must not be reused for a 2-D dataset (rank 2). Read the 1-D one
|
||||||
|
// first so it seeds the shared cache, then the 2-D one.
|
||||||
|
use clawhdf5_format::datatype::{CharacterSet, Datatype, StringPadding};
|
||||||
|
|
||||||
|
// 1-D chunked + compressed fixed-length strings (payload > compress threshold).
|
||||||
|
let strings: Vec<String> = (0..64)
|
||||||
|
.map(|i| format!("entry-{i:06}-{}", "x".repeat(80)))
|
||||||
|
.collect();
|
||||||
|
let max_len = strings.iter().map(|s| s.len()).max().unwrap();
|
||||||
|
let mut sraw = Vec::new();
|
||||||
|
for s in &strings {
|
||||||
|
let mut b = s.as_bytes().to_vec();
|
||||||
|
b.resize(max_len, 0);
|
||||||
|
sraw.extend_from_slice(&b);
|
||||||
|
}
|
||||||
|
let sdt = Datatype::String {
|
||||||
|
size: max_len as u32,
|
||||||
|
padding: StringPadding::NullPad,
|
||||||
|
charset: CharacterSet::Utf8,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2-D chunked + compressed f32 matrix.
|
||||||
|
let (n, d) = (40usize, 8usize);
|
||||||
|
let mat: Vec<f32> = (0..n * d).map(|i| i as f32).collect();
|
||||||
|
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
{
|
||||||
|
let ds = b.create_dataset("strs");
|
||||||
|
ds.with_compound_data(sdt, sraw, strings.len() as u64);
|
||||||
|
ds.with_chunks(&[16]);
|
||||||
|
ds.with_deflate(6);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let ds = b.create_dataset("mat");
|
||||||
|
ds.with_f32_data(&mat).with_shape(&[n as u64, d as u64]);
|
||||||
|
ds.with_chunks(&[10, d as u64])
|
||||||
|
.with_shuffle()
|
||||||
|
.with_deflate(6);
|
||||||
|
}
|
||||||
|
let bytes = b.finish().unwrap();
|
||||||
|
let file = File::from_bytes(bytes).unwrap();
|
||||||
|
|
||||||
|
// Read the 1-D dataset first (seeds the shared cache with a rank-1 index),
|
||||||
|
// then the 2-D dataset through the same File/cache.
|
||||||
|
let got_strs = file.dataset("strs").unwrap().read_string().unwrap();
|
||||||
|
assert_eq!(got_strs, strings);
|
||||||
|
let got_mat = file.dataset("mat").unwrap().read_f32().unwrap();
|
||||||
|
assert_eq!(got_mat, mat);
|
||||||
|
// Read the 1-D one again to confirm the cache rebinds back correctly.
|
||||||
|
assert_eq!(
|
||||||
|
file.dataset("strs").unwrap().read_string().unwrap(),
|
||||||
|
strings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn virtual_dataset_external_file_auto_resolved() {
|
||||||
|
// The facade resolves external Virtual Dataset sources relative to the
|
||||||
|
// opened file's directory automatically. Drop both files side by side in a
|
||||||
|
// temp dir and open the virtual one through the public File API.
|
||||||
|
let virt = include_bytes!("../../clawhdf5-format/tests/fixtures/vds_external_virt.h5");
|
||||||
|
let src = include_bytes!("../../clawhdf5-format/tests/fixtures/vds_external_src.h5");
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("ext_src.h5"), src).unwrap();
|
||||||
|
let virt_path = dir.path().join("ext_virt.h5");
|
||||||
|
std::fs::write(&virt_path, virt).unwrap();
|
||||||
|
|
||||||
|
let file = File::open(&virt_path).unwrap();
|
||||||
|
let values = file.dataset("virt").unwrap().read_i32().unwrap();
|
||||||
|
assert_eq!(values, vec![10, 11, 12, 13, 14, 15, 16, 17]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn virtual_dataset_external_missing_source_is_fill() {
|
||||||
|
// If the external source file is absent, its region reads as the zero fill
|
||||||
|
// value rather than erroring.
|
||||||
|
let virt = include_bytes!("../../clawhdf5-format/tests/fixtures/vds_external_virt.h5");
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let virt_path = dir.path().join("ext_virt.h5");
|
||||||
|
std::fs::write(&virt_path, virt).unwrap();
|
||||||
|
|
||||||
|
let file = File::open(&virt_path).unwrap();
|
||||||
|
let values = file.dataset("virt").unwrap().read_i32().unwrap();
|
||||||
|
assert_eq!(values, vec![0; 8]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn same_dataset_name_in_different_groups() {
|
fn same_dataset_name_in_different_groups() {
|
||||||
let mut b = FileBuilder::new();
|
let mut b = FileBuilder::new();
|
||||||
@@ -730,3 +821,117 @@ fn same_dataset_name_in_different_groups() {
|
|||||||
vec![3.0, 4.0]
|
vec![3.0, 4.0]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dense_group_links_roundtrip() {
|
||||||
|
// A group with more than the compact threshold (8) of links is stored
|
||||||
|
// densely (fractal heap + v2 B-tree). It must round-trip; a small sibling
|
||||||
|
// group stays compact. Names are chosen so hashes are non-trivial.
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
let mut big = b.create_group("big");
|
||||||
|
for i in 0..20 {
|
||||||
|
big.create_dataset(&format!("dataset_{i:03}"))
|
||||||
|
.with_i32_data(&[i, i * 2, i * 3]);
|
||||||
|
}
|
||||||
|
b.add_group(big.finish());
|
||||||
|
let mut small = b.create_group("small");
|
||||||
|
small.create_dataset("a").with_f64_data(&[1.0]);
|
||||||
|
small.create_dataset("b").with_f64_data(&[2.0]);
|
||||||
|
b.add_group(small.finish());
|
||||||
|
let bytes = b.finish().unwrap();
|
||||||
|
|
||||||
|
let file = File::from_bytes(bytes).unwrap();
|
||||||
|
|
||||||
|
// All 20 dense-group links resolve, with correct data.
|
||||||
|
let mut names = file.group("big").unwrap().datasets().unwrap();
|
||||||
|
names.sort();
|
||||||
|
assert_eq!(names.len(), 20);
|
||||||
|
for i in 0..20 {
|
||||||
|
assert_eq!(
|
||||||
|
file.dataset(&format!("big/dataset_{i:03}"))
|
||||||
|
.unwrap()
|
||||||
|
.read_i32()
|
||||||
|
.unwrap(),
|
||||||
|
vec![i, i * 2, i * 3],
|
||||||
|
"dense link {i} mismatch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The small (compact) group still works.
|
||||||
|
assert_eq!(
|
||||||
|
file.dataset("small/a").unwrap().read_f64().unwrap(),
|
||||||
|
vec![1.0]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
file.dataset("small/b").unwrap().read_f64().unwrap(),
|
||||||
|
vec![2.0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_libhdf5_multiblock_fractal_heap() {
|
||||||
|
// A group whose dense attributes overflow a single fractal-heap direct
|
||||||
|
// block, so libhdf5 stored them under a root indirect block (FHIB) with
|
||||||
|
// multiple direct blocks. Reading requires deriving the direct/indirect row
|
||||||
|
// split from the heap geometry, not the FRHP "starting rows" field.
|
||||||
|
let bytes = include_bytes!("../../clawhdf5-format/tests/fixtures/fractal_heap_multiblock.h5");
|
||||||
|
let file = File::from_bytes(bytes.to_vec()).unwrap();
|
||||||
|
let attrs = file.group("g").unwrap().attrs().unwrap();
|
||||||
|
for i in 0..80i64 {
|
||||||
|
let name = format!("a{i:03}");
|
||||||
|
match attrs.get(&name) {
|
||||||
|
Some(AttrValue::I64(v)) => assert_eq!(*v, i * 3, "{name}"),
|
||||||
|
other => panic!("{name} = {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dense_attrs_multiblock_fractal_heap_roundtrip() {
|
||||||
|
// Enough dense attributes to overflow a single 64 KiB fractal-heap direct
|
||||||
|
// block, forcing a root indirect block over multiple direct blocks.
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
let mut g = b.create_group("g");
|
||||||
|
let n = 1600i64;
|
||||||
|
for i in 0..n {
|
||||||
|
g.set_attr(&format!("attribute_number_{i:05}"), AttrValue::I64(i * 2));
|
||||||
|
}
|
||||||
|
g.create_dataset("d").with_i32_data(&[1]);
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let file = File::from_bytes(b.finish().unwrap()).unwrap();
|
||||||
|
|
||||||
|
let attrs = file.group("g").unwrap().attrs().unwrap();
|
||||||
|
for i in 0..n {
|
||||||
|
match attrs.get(&format!("attribute_number_{i:05}")) {
|
||||||
|
Some(AttrValue::I64(v)) => assert_eq!(*v, i * 2, "attr {i}"),
|
||||||
|
other => panic!("attr {i} = {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dense_links_multiblock_fractal_heap_roundtrip() {
|
||||||
|
// Enough links to overflow a single fractal-heap direct block.
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
let mut g = b.create_group("big");
|
||||||
|
let n = 2200;
|
||||||
|
for i in 0..n {
|
||||||
|
g.create_dataset(&format!("dataset_number_{i:05}"))
|
||||||
|
.with_i32_data(&[i]);
|
||||||
|
}
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let file = File::from_bytes(b.finish().unwrap()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
file.group("big").unwrap().datasets().unwrap().len(),
|
||||||
|
n as usize
|
||||||
|
);
|
||||||
|
for i in [0, 1, 1234, n - 1] {
|
||||||
|
assert_eq!(
|
||||||
|
file.dataset(&format!("big/dataset_number_{i:05}"))
|
||||||
|
.unwrap()
|
||||||
|
.read_i32()
|
||||||
|
.unwrap(),
|
||||||
|
vec![i]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,27 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
// Fallback: look for libaec.so / libaec.a in standard library paths.
|
||||||
|
// libaec-dev on Debian/Ubuntu installs the library but omits the .pc file.
|
||||||
|
let lib_dirs = [
|
||||||
|
"/usr/lib/x86_64-linux-gnu",
|
||||||
|
"/usr/lib",
|
||||||
|
"/usr/local/lib",
|
||||||
|
"/usr/local/lib/x86_64-linux-gnu",
|
||||||
|
];
|
||||||
|
for dir in &lib_dirs {
|
||||||
|
let so = std::path::Path::new(dir).join("libaec.so");
|
||||||
|
let a = std::path::Path::new(dir).join("libaec.a");
|
||||||
|
if so.exists() || a.exists() {
|
||||||
|
println!("cargo:rustc-link-search=native={dir}");
|
||||||
|
println!("cargo:rustc-link-lib=aec");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// libaec not found — szip feature will be unavailable but crate still compiles.
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//! Raw FFI bindings to libaec (Adaptive Entropy Coding library).
|
||||||
|
//!
|
||||||
|
//! Exposes `aec_buffer_encode` and `aec_buffer_decode` via the `AecStream`
|
||||||
|
//! control structure, matching the libaec C API defined in `<libaec.h>`.
|
||||||
|
|
||||||
|
use std::os::raw::c_void;
|
||||||
|
|
||||||
|
// AEC flag constants — values match <libaec.h> exactly.
|
||||||
|
pub const AEC_DATA_SIGNED: u32 = 1;
|
||||||
|
pub const AEC_DATA_3BYTE: u32 = 2;
|
||||||
|
pub const AEC_DATA_MSB: u32 = 4;
|
||||||
|
pub const AEC_DATA_PREPROCESS: u32 = 8;
|
||||||
|
pub const AEC_RESTRICTED: u32 = 16;
|
||||||
|
|
||||||
|
/// Mirror of `struct aec_stream` from `<libaec.h>`.
|
||||||
|
///
|
||||||
|
/// Must match the C layout exactly — all fields are C ABI integers/pointers.
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct AecStream {
|
||||||
|
pub next_in: *const u8,
|
||||||
|
pub avail_in: usize,
|
||||||
|
pub total_in: usize,
|
||||||
|
pub next_out: *mut u8,
|
||||||
|
pub avail_out: usize,
|
||||||
|
pub total_out: usize,
|
||||||
|
pub bits_per_sample: u32,
|
||||||
|
pub block_size: u32,
|
||||||
|
pub rsi: u32,
|
||||||
|
pub flags: u32,
|
||||||
|
/// Opaque internal state; initialised to null, set by libaec on first call.
|
||||||
|
pub state: *mut c_void,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AecStream {
|
||||||
|
/// Return a zero-initialised stream safe to pass to libaec.
|
||||||
|
pub fn zeroed() -> Self {
|
||||||
|
Self {
|
||||||
|
next_in: std::ptr::null(),
|
||||||
|
avail_in: 0,
|
||||||
|
total_in: 0,
|
||||||
|
next_out: std::ptr::null_mut(),
|
||||||
|
avail_out: 0,
|
||||||
|
total_out: 0,
|
||||||
|
bits_per_sample: 0,
|
||||||
|
block_size: 0,
|
||||||
|
rsi: 0,
|
||||||
|
flags: 0,
|
||||||
|
state: std::ptr::null_mut(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "C" {
|
||||||
|
/// One-shot compression. Returns `AEC_OK` (0) on success.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `strm.next_in` must be valid for `strm.avail_in` bytes;
|
||||||
|
/// `strm.next_out` must be valid for `strm.avail_out` bytes.
|
||||||
|
pub fn aec_buffer_encode(strm: *mut AecStream) -> i32;
|
||||||
|
|
||||||
|
/// One-shot decompression. Returns `AEC_OK` (0) on success.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `strm.next_in` must be valid for `strm.avail_in` bytes;
|
||||||
|
/// `strm.next_out` must be valid for `strm.avail_out` bytes.
|
||||||
|
pub fn aec_buffer_decode(strm: *mut AecStream) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constants_match_libaec_header() {
|
||||||
|
assert_eq!(AEC_DATA_SIGNED, 1);
|
||||||
|
assert_eq!(AEC_DATA_3BYTE, 2);
|
||||||
|
assert_eq!(AEC_DATA_MSB, 4);
|
||||||
|
assert_eq!(AEC_DATA_PREPROCESS, 8);
|
||||||
|
assert_eq!(AEC_RESTRICTED, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aec_stream_zeroed_has_null_ptrs() {
|
||||||
|
let s = AecStream::zeroed();
|
||||||
|
assert!(s.next_in.is_null());
|
||||||
|
assert!(s.next_out.is_null());
|
||||||
|
assert!(s.state.is_null());
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -344,7 +344,7 @@ let data = temp.read_f64()?;
|
|||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
ClawhDF5 is 2–300× faster than h5py/C HDF5 for common operations. See [BENCHMARKS.md](../BENCHMARKS.md) for details. The zero-copy mmap path reads 1M floats in 313 nanoseconds.
|
ClawhDF5 is 3–45× faster than libhdf5 for common operations (see [BENCHMARKS.md](../BENCHMARKS.md#vs-libhdf5-summary) for methodology and an independent second-machine reproduction).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -548,7 +548,7 @@ let final_results = confidence::reject_low_confidence(
|
|||||||
- Hierarchical groups (natural fit for entity/relation/session organization)
|
- Hierarchical groups (natural fit for entity/relation/session organization)
|
||||||
- Compression built in (zlib, lz4, zstd)
|
- Compression built in (zlib, lz4, zstd)
|
||||||
- Battle-tested format (30+ years in scientific computing)
|
- Battle-tested format (30+ years in scientific computing)
|
||||||
- Our implementation is pure Rust, 2–300× faster than C HDF5 for metadata ops
|
- Our implementation is pure Rust, 10–11× faster than libhdf5 for metadata ops (attribute writes, group creation) — see [BENCHMARKS.md](../BENCHMARKS.md#vs-libhdf5-summary)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,656 @@
|
|||||||
|
# Filter Codecs Implementation Plan
|
||||||
|
|
||||||
|
> **Status (2026-08-03):** Implemented — shipped in commit `d6c4d4f` (2026-06-30), with FFI/constant fixes in `cb0b0e9`/`e91f7fc`. This doc was authored 2026-06-29 as the pre-work plan and committed to the repo retroactively on 2026-08-03; checkboxes below have been marked complete to match. Treat this as a historical record, not an open task list.
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add SZIP decompression (filter ID 4) and N-Bit E-scale decompression (scale type 1 of filter ID 6) to the clawhdf5-format crate.
|
||||||
|
|
||||||
|
**Architecture:** N-Bit E-scale extends the existing `scaleoffset_decompress` function in `filters.rs` with a ~15-line new branch. SZIP is added as an optional `szip` feature using FFI to the system `libaec` C library (same pattern as the existing `system-zlib-decompress` feature), with a `build.rs` that uses `pkg-config` or `cc` to locate/compile it.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (no_std-compatible where possible), `libaec` C library (optional FFI via `cc` crate), `pkg-config` crate for system library discovery.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- All code in `crates/clawhdf5-format/` and `crates/clawhdf5-filters/`.
|
||||||
|
- SZIP must be feature-gated: `szip` feature, disabled by default. When not enabled, `FILTER_SZIP` must return `FormatError::UnsupportedFilter(4)` as it does today.
|
||||||
|
- N-Bit E-scale requires no new features — it is a fix within the existing `deflate`-free path.
|
||||||
|
- Tests must not require h5py or Python; use hand-crafted compressed byte sequences verified against the HDF5 reference implementation commentary in the test file.
|
||||||
|
- Run `cargo test -p clawhdf5-format` after every task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: N-Bit E-scale (float scale-offset, scale type 1)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/clawhdf5-format/src/filters.rs:96-108` (the `scaleoffset_decompress` dispatch block)
|
||||||
|
- Test: `crates/clawhdf5-format/src/filters.rs` (new tests in the existing `#[cfg(test)]` block at the bottom)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: existing `scaleoffset_decompress(data: &[u8], cd: &[u32], expected_bytes: usize) -> Result<Vec<u8>, FormatError>`.
|
||||||
|
- Produces: same function, now handling `cd[0] == 1` (H5Z_SO_FLOAT_ESCALE).
|
||||||
|
|
||||||
|
**Background:**
|
||||||
|
- `cd[0]`: scale type — `0` = float D-scale (already done), `1` = float E-scale (this task), `2` = integer (already done).
|
||||||
|
- E-scale formula: `value = minval + code * 2^E` where `E = cd[1] as i32` (may be negative for sub-unit precision). Compare to D-scale: `value = minval + code / 10^D`.
|
||||||
|
- The binary layout (minbits, minval, 8 reserved bytes, packed MSB-first codes) is IDENTICAL to D-scale. Only the reconstruction formula differs.
|
||||||
|
|
||||||
|
- [x] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
In `crates/clawhdf5-format/src/filters.rs`, inside the existing `#[cfg(test)] mod tests` block, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn scaleoffset_float_escale_basic() {
|
||||||
|
// f32 [0.0, 4.0, 8.0, 12.0]: minval=0.0f32, E=2 (scale=4.0 = 2^2),
|
||||||
|
// stored codes [0, 1, 2, 3] in 2 bits each.
|
||||||
|
// cd: [scale_type=1, scale_factor=2, nelmts=4, unused=1, elem_size=4,
|
||||||
|
// signed=0, big_endian=0, fill_defined=1, fill_lo=0, fill_hi=0]
|
||||||
|
let cd = [1u32, 2, 4, 1, 4, 0, 0, 1, 0, 0];
|
||||||
|
// Layout: minbits(4 LE) = 2, minval_width(1) = 4, minval(4) = 0.0f32,
|
||||||
|
// reserved(8), packed codes: 0b_00_01_10_11 = 0x1B in 1 byte
|
||||||
|
let mut data = Vec::new();
|
||||||
|
data.extend_from_slice(&2u32.to_le_bytes()); // minbits = 2
|
||||||
|
data.push(4); // minval_width
|
||||||
|
data.extend_from_slice(&0.0f32.to_le_bytes()); // minval = 0.0
|
||||||
|
data.extend_from_slice(&[0u8; 8]); // 8 reserved bytes
|
||||||
|
data.push(0b0001_1011); // codes: 0,1,2,3 packed MSB-first in 2 bits each
|
||||||
|
let out = scaleoffset_decompress(&data, &cd, 0).unwrap();
|
||||||
|
let floats: Vec<f32> = out.chunks_exact(4)
|
||||||
|
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(floats.len(), 4);
|
||||||
|
assert!((floats[0] - 0.0f32).abs() < 1e-5, "got {}", floats[0]);
|
||||||
|
assert!((floats[1] - 4.0f32).abs() < 1e-5, "got {}", floats[1]);
|
||||||
|
assert!((floats[2] - 8.0f32).abs() < 1e-5, "got {}", floats[2]);
|
||||||
|
assert!((floats[3] - 12.0f32).abs() < 1e-5, "got {}", floats[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scaleoffset_float_escale_negative_exponent() {
|
||||||
|
// f32 [0.0, 0.25, 0.5, 0.75]: minval=0.0, E=-2 (scale=0.25 = 2^-2),
|
||||||
|
// codes [0,1,2,3]. cd[1] stored as u32; we cast to i32 in decoder.
|
||||||
|
let e: i32 = -2;
|
||||||
|
let cd = [1u32, e as u32, 4, 1, 4, 0, 0, 1, 0, 0];
|
||||||
|
let mut data = Vec::new();
|
||||||
|
data.extend_from_slice(&2u32.to_le_bytes());
|
||||||
|
data.push(4);
|
||||||
|
data.extend_from_slice(&0.0f32.to_le_bytes());
|
||||||
|
data.extend_from_slice(&[0u8; 8]);
|
||||||
|
data.push(0b0001_1011);
|
||||||
|
let out = scaleoffset_decompress(&data, &cd, 0).unwrap();
|
||||||
|
let floats: Vec<f32> = out.chunks_exact(4)
|
||||||
|
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
|
||||||
|
.collect();
|
||||||
|
assert!((floats[1] - 0.25f32).abs() < 1e-6, "got {}", floats[1]);
|
||||||
|
assert!((floats[2] - 0.50f32).abs() < 1e-6, "got {}", floats[2]);
|
||||||
|
assert!((floats[3] - 0.75f32).abs() < 1e-6, "got {}", floats[3]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format scaleoffset_float_escale 2>&1 | head -30
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `"UnsupportedFilter(6)"` or similar.
|
||||||
|
|
||||||
|
- [x] **Step 3: Implement E-scale in scaleoffset_decompress**
|
||||||
|
|
||||||
|
In `crates/clawhdf5-format/src/filters.rs`, change the dispatch block (around line 96):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn scaleoffset_decompress(
|
||||||
|
data: &[u8],
|
||||||
|
cd: &[u32],
|
||||||
|
expected_bytes: usize,
|
||||||
|
) -> Result<Vec<u8>, FormatError> {
|
||||||
|
const H5Z_SO_FLOAT_DSCALE: u32 = 0;
|
||||||
|
const H5Z_SO_FLOAT_ESCALE: u32 = 1;
|
||||||
|
const H5Z_SO_INT: u32 = 2;
|
||||||
|
if cd.len() < 8 {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"scale-offset: missing filter client data".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let scale_type = cd[0];
|
||||||
|
let is_float = scale_type == H5Z_SO_FLOAT_DSCALE || scale_type == H5Z_SO_FLOAT_ESCALE;
|
||||||
|
if scale_type != H5Z_SO_INT && !is_float {
|
||||||
|
return Err(FormatError::UnsupportedFilter(FILTER_SCALEOFFSET));
|
||||||
|
}
|
||||||
|
// ... (rest of the existing parsing logic unchanged until the reconstruction block) ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in the float reconstruction block (currently the `if is_float { ... }` branch at line ~192), replace:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if is_float {
|
||||||
|
let scale = if scale_type == H5Z_SO_FLOAT_DSCALE {
|
||||||
|
10f64.powi(cd[1] as i32)
|
||||||
|
} else {
|
||||||
|
// E-scale: scale factor is a power of 2; cd[1] interpreted as signed i32
|
||||||
|
2f64.powi(cd[1] as i32)
|
||||||
|
};
|
||||||
|
let minval = read_le_float(minval_bytes, elem_size);
|
||||||
|
let fill_value = if fill_defined {
|
||||||
|
let lo = *cd.get(8).unwrap_or(&0) as u64;
|
||||||
|
let hi = *cd.get(9).unwrap_or(&0) as u64;
|
||||||
|
bits_to_float(lo | (hi << 32), elem_size)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let values: Vec<f64> = codes
|
||||||
|
.iter()
|
||||||
|
.map(|&code| {
|
||||||
|
if has_fill_code && code == fill_code {
|
||||||
|
fill_value
|
||||||
|
} else if scale_type == H5Z_SO_FLOAT_DSCALE {
|
||||||
|
minval + code as f64 / scale
|
||||||
|
} else {
|
||||||
|
// E-scale: value = minval + code * 2^E
|
||||||
|
minval + code as f64 * scale
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(write_floats(&values, elem_size, big_endian))
|
||||||
|
} else {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format scaleoffset_float_escale 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both tests PASS.
|
||||||
|
|
||||||
|
- [x] **Step 5: Run full test suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass, zero failures.
|
||||||
|
|
||||||
|
- [x] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/clawhdf5-format/src/filters.rs
|
||||||
|
git commit -m "feat: add scale-offset E-scale (float binary-exponent) decompression"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: SZIP feature gate and stub hook
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/clawhdf5-format/Cargo.toml` (add `szip` feature and `libaec-sys` optional dep)
|
||||||
|
- Create: `crates/clawhdf5-format/build.rs`
|
||||||
|
- Modify: `crates/clawhdf5-format/src/filters.rs` (add `szip_decompress` call in `decompress_chunk`)
|
||||||
|
- Create: `crates/clawhdf5-format/src/filters_szip.rs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `pub(crate) fn szip_decompress(data: &[u8], cd: &[u32], chunk_size: usize) -> Result<Vec<u8>, FormatError>`
|
||||||
|
- `decompress_chunk` calls it for `FILTER_SZIP` when the `szip` feature is active.
|
||||||
|
|
||||||
|
**Background — SZIP parameters from `cd`:**
|
||||||
|
- `cd[0]` (options mask): bit 2 = NN (nearest-neighbor) preprocessing, bit 4 = EC (entropy coding), bit 5 = LSB order, bit 8 = allow K-13.
|
||||||
|
- `cd[1]` (pixels per block): 8, 10, 16, or 32.
|
||||||
|
- `cd[2]` (pixels per scan line): not used for decompression.
|
||||||
|
- The `libaec` library exposes `aec_decode_init`, `aec_decode`, `aec_decode_end` (struct `aec_stream`).
|
||||||
|
|
||||||
|
- [x] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
In `crates/clawhdf5-format/src/filters_szip.rs` (create the file):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
//! SZIP (libaec Adaptive Entropy Coding) decompression.
|
||||||
|
//!
|
||||||
|
//! Gated by the `szip` feature which links against the system libaec library.
|
||||||
|
|
||||||
|
use crate::error::FormatError;
|
||||||
|
use crate::filter_pipeline::FILTER_SZIP;
|
||||||
|
|
||||||
|
/// Decompress SZIP-compressed data using libaec.
|
||||||
|
///
|
||||||
|
/// `cd` is the HDF5 filter client data:
|
||||||
|
/// cd[0] = options mask (EC flag = 0x04, NN flag = 0x20, 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 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(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] as usize;
|
||||||
|
if bits_per_sample == 0 || bits_per_sample > 32 {
|
||||||
|
return Err(FormatError::ChunkedReadError("szip: invalid bits per sample".into()));
|
||||||
|
}
|
||||||
|
// Map HDF5 options to libaec flags
|
||||||
|
let flags: u32 = {
|
||||||
|
let mut f = 0u32;
|
||||||
|
if options & 0x04 != 0 { f |= AEC_DATA_PREPROCESS; } // NN
|
||||||
|
if options & 0x40 == 0 { f |= AEC_DATA_MSB; } // MSB (not LSB)
|
||||||
|
if options & 0x100 != 0 { f |= AEC_ALLOW_K13; }
|
||||||
|
f
|
||||||
|
};
|
||||||
|
let out_len = if chunk_size > 0 { chunk_size } else {
|
||||||
|
return Err(FormatError::ChunkedReadError("szip: unknown output size".into()));
|
||||||
|
};
|
||||||
|
let mut out = vec![0u8; out_len];
|
||||||
|
let result = unsafe {
|
||||||
|
libaec_sys::aec_buffer_decode(
|
||||||
|
data.as_ptr(),
|
||||||
|
data.len(),
|
||||||
|
out.as_mut_ptr(),
|
||||||
|
&mut (out_len as libaec_sys::size_t),
|
||||||
|
bits_per_sample as u32,
|
||||||
|
pixels_per_block,
|
||||||
|
flags,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if result != 0 {
|
||||||
|
return Err(FormatError::DecompressionError(format!("szip: libaec error {result}")));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// libaec flag constants (from aec.h)
|
||||||
|
#[cfg(feature = "szip")]
|
||||||
|
const AEC_DATA_PREPROCESS: u32 = 1;
|
||||||
|
#[cfg(feature = "szip")]
|
||||||
|
const AEC_DATA_MSB: u32 = 2;
|
||||||
|
#[cfg(feature = "szip")]
|
||||||
|
const AEC_ALLOW_K13: u32 = 8;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn szip_disabled_returns_unsupported() {
|
||||||
|
// When szip feature is disabled, must return UnsupportedFilter(4).
|
||||||
|
#[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 _ = szip_decompress(&[], &[4, 8, 10, 0, 8], 64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run the new test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format szip_disabled_returns_unsupported 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the file doesn't compile yet (module not declared). That's the expected failure mode.
|
||||||
|
|
||||||
|
- [x] **Step 3: Add Cargo.toml feature and build.rs**
|
||||||
|
|
||||||
|
In `crates/clawhdf5-format/Cargo.toml`, add to `[dependencies]`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
libaec-sys = { version = "0.1", optional = true }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `[features]`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
szip = ["libaec-sys"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `crates/clawhdf5-format/build.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn main() {
|
||||||
|
#[cfg(feature = "szip")]
|
||||||
|
{
|
||||||
|
// Try pkg-config first; fall back to empty link flags (system path).
|
||||||
|
if std::process::Command::new("pkg-config")
|
||||||
|
.args(["--exists", "libaec"])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
println!("cargo:rustc-link-lib=aec");
|
||||||
|
if let Ok(dir) = std::process::Command::new("pkg-config")
|
||||||
|
.args(["--variable=libdir", "libaec"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
let dir = String::from_utf8_lossy(&dir.stdout).trim().to_string();
|
||||||
|
if !dir.is_empty() {
|
||||||
|
println!("cargo:rustc-link-search=native={dir}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: assume libaec is in the standard library path.
|
||||||
|
println!("cargo:rustc-link-lib=aec");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `libaec-sys` is a crate that provides raw bindings. If that crate doesn't exist on crates.io with that exact name, use `libaec-sys = { git = "..." }` or add `aec-sys` as a local crate (see Task 3 below for the fallback path).
|
||||||
|
|
||||||
|
- [x] **Step 4: Declare the module in lib.rs**
|
||||||
|
|
||||||
|
In `crates/clawhdf5-format/src/lib.rs`, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
mod filters_szip;
|
||||||
|
```
|
||||||
|
|
||||||
|
(Place it alongside the other `mod filters;` declaration.)
|
||||||
|
|
||||||
|
- [x] **Step 5: Hook szip_decompress into decompress_chunk**
|
||||||
|
|
||||||
|
In `crates/clawhdf5-format/src/filters.rs`, change the dispatch inside `decompress_chunk`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Change this:
|
||||||
|
other => return Err(FormatError::UnsupportedFilter(other)),
|
||||||
|
// To this:
|
||||||
|
FILTER_SZIP => crate::filters_szip::szip_decompress(&data, &filter.client_data, chunk_size)?,
|
||||||
|
other => return Err(FormatError::UnsupportedFilter(other)),
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add the import at the top of `filters.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::filter_pipeline::{
|
||||||
|
FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_NBIT, FILTER_SCALEOFFSET,
|
||||||
|
FILTER_SHUFFLE, FILTER_SZIP, FILTER_ZSTD, FilterPipeline,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
(Add `FILTER_SZIP` to the existing import.)
|
||||||
|
|
||||||
|
- [x] **Step 6: Run tests without szip feature**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format 2>&1 | tail -15
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all existing tests pass; `szip_disabled_returns_unsupported` passes.
|
||||||
|
|
||||||
|
- [x] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/clawhdf5-format/Cargo.toml \
|
||||||
|
crates/clawhdf5-format/build.rs \
|
||||||
|
crates/clawhdf5-format/src/filters_szip.rs \
|
||||||
|
crates/clawhdf5-format/src/filters.rs \
|
||||||
|
crates/clawhdf5-format/src/lib.rs
|
||||||
|
git commit -m "feat: add SZIP filter hook with libaec FFI (feature-gated, disabled by default)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: libaec-sys bindings crate (if no public crate exists)
|
||||||
|
|
||||||
|
> Skip this task if a published `libaec-sys` crate is available on crates.io. Check with `cargo search libaec-sys`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `crates/libaec-sys/Cargo.toml`
|
||||||
|
- Create: `crates/libaec-sys/src/lib.rs`
|
||||||
|
- Create: `crates/libaec-sys/build.rs`
|
||||||
|
- Modify: `Cargo.toml` (workspace members)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `pub unsafe 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`
|
||||||
|
|
||||||
|
- [x] **Step 1: Create the sys crate**
|
||||||
|
|
||||||
|
Create `crates/libaec-sys/Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[package]
|
||||||
|
name = "libaec-sys"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
links = "aec"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
pkg-config = "0.3"
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `crates/libaec-sys/build.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn main() {
|
||||||
|
if pkg_config::Config::new()
|
||||||
|
.atleast_version("1.0")
|
||||||
|
.probe("libaec")
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If pkg-config fails, try linking directly
|
||||||
|
println!("cargo:rustc-link-lib=aec");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `crates/libaec-sys/src/lib.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
//! Raw FFI bindings to libaec (Adaptive Entropy Coding library).
|
||||||
|
//!
|
||||||
|
//! Provides the `aec_buffer_decode` convenience function for one-shot decompression.
|
||||||
|
|
||||||
|
pub type size_t = usize;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
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: size_t,
|
||||||
|
dst: *mut u8,
|
||||||
|
dst_len: *mut size_t,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Add to workspace**
|
||||||
|
|
||||||
|
In the root `Cargo.toml`, add `"crates/libaec-sys"` to `[workspace] members`.
|
||||||
|
|
||||||
|
- [x] **Step 3: Update clawhdf5-format dependency**
|
||||||
|
|
||||||
|
In `crates/clawhdf5-format/Cargo.toml`, change:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
libaec-sys = { version = "0.1", optional = true }
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
libaec-sys = { path = "../libaec-sys", version = "0.1", optional = true }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Run tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p libaec-sys 2>&1 | tail -10
|
||||||
|
cargo test -p clawhdf5-format 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both pass.
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/libaec-sys/ Cargo.toml crates/clawhdf5-format/Cargo.toml
|
||||||
|
git commit -m "feat: add libaec-sys workspace crate for SZIP FFI bindings"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: SZIP integration test with libaec installed
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/clawhdf5-format/src/filters_szip.rs` (add integration test behind `szip` feature)
|
||||||
|
|
||||||
|
**Background:** This test only runs when the `szip` feature is enabled AND libaec is installed. It validates that we can round-trip a known dataset (u8 values 0-63, 8 pixels per block, EC mode).
|
||||||
|
|
||||||
|
- [x] **Step 1: Add integration test**
|
||||||
|
|
||||||
|
In `crates/clawhdf5-format/src/filters_szip.rs`, inside `#[cfg(test)] mod tests`, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
#[cfg(feature = "szip")]
|
||||||
|
fn szip_ec_roundtrip_u8() {
|
||||||
|
// Encode 64 values [0..64] with libaec, then decode with our wrapper.
|
||||||
|
// This tests the full encode→decode cycle.
|
||||||
|
use crate::filter_pipeline::FilterDescription;
|
||||||
|
use crate::filters::{compress_chunk, decompress_chunk};
|
||||||
|
use crate::filter_pipeline::{FilterPipeline, FILTER_SZIP};
|
||||||
|
|
||||||
|
// cd: options=EC(0x04)|MSB(0x00), pixels_per_block=8, ppsl=64, unused=0, bits_per_sample=8
|
||||||
|
let cd = vec![0x04u32, 8, 64, 0, 8];
|
||||||
|
|
||||||
|
// Build a test dataset: 64 bytes incrementing
|
||||||
|
let original: Vec<u8> = (0u8..64).collect();
|
||||||
|
|
||||||
|
// Use aec_buffer_encode to generate compressed data for this test
|
||||||
|
let compressed = unsafe {
|
||||||
|
let mut out = vec![0u8; original.len() * 4]; // generous buffer
|
||||||
|
let mut out_len = out.len();
|
||||||
|
libaec_sys::aec_buffer_encode(
|
||||||
|
original.as_ptr(),
|
||||||
|
original.len(),
|
||||||
|
out.as_mut_ptr(),
|
||||||
|
&mut out_len,
|
||||||
|
8, // bits per sample
|
||||||
|
8, // block size
|
||||||
|
libaec_sys::AEC_DATA_MSB,
|
||||||
|
);
|
||||||
|
out.truncate(out_len);
|
||||||
|
out
|
||||||
|
};
|
||||||
|
|
||||||
|
let decoded = szip_decompress(&compressed, &cd, original.len()).unwrap();
|
||||||
|
assert_eq!(decoded, original);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add `aec_buffer_encode` to `crates/libaec-sys/src/lib.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
extern "C" {
|
||||||
|
// ... existing aec_buffer_decode ...
|
||||||
|
|
||||||
|
/// One-shot compression. 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_encode(
|
||||||
|
src: *const u8,
|
||||||
|
src_len: size_t,
|
||||||
|
dst: *mut u8,
|
||||||
|
dst_len: *mut size_t,
|
||||||
|
bits_per_sample: u32,
|
||||||
|
block_size: u32,
|
||||||
|
flags: u32,
|
||||||
|
) -> i32;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run the integration test (requires libaec installed)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install libaec if not present: sudo apt install libaec-dev
|
||||||
|
cargo test -p clawhdf5-format --features szip szip_ec_roundtrip_u8 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS when libaec is installed.
|
||||||
|
|
||||||
|
- [x] **Step 3: Run full suite without szip feature to verify no regressions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/clawhdf5-format/src/filters_szip.rs crates/libaec-sys/src/lib.rs
|
||||||
|
git commit -m "feat: add SZIP integration test for libaec roundtrip"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full test suite (no szip)
|
||||||
|
cargo test -p clawhdf5-format 2>&1 | tail -5
|
||||||
|
|
||||||
|
# With szip feature (requires libaec installed)
|
||||||
|
cargo test -p clawhdf5-format --features szip 2>&1 | tail -5
|
||||||
|
|
||||||
|
# Specific E-scale tests
|
||||||
|
cargo test -p clawhdf5-format scaleoffset_float_escale 2>&1
|
||||||
|
|
||||||
|
# Confirm SZIP returns UnsupportedFilter without the feature
|
||||||
|
cargo test -p clawhdf5-format szip_disabled 2>&1
|
||||||
|
```
|
||||||
@@ -0,0 +1,846 @@
|
|||||||
|
# Format Write Extensions Implementation Plan
|
||||||
|
|
||||||
|
> **Status (2026-08-03):** Implemented. Tasks 1–3 (external links, VDS mapping serialization, VDS `FileWriter` API) shipped in commit `d6c4d4f` (2026-06-30). Tasks 4–5 (superblock v4 read/write) were not part of that commit and were completed separately as part of this cleanup pass (2026-08-03) — see `Superblock::parse_v4`/`serialize` and `FileWriter::with_page_size` in `crates/clawhdf5-format`. This doc was authored 2026-06-29 as the pre-work plan and committed to the repo retroactively; checkboxes below have been marked complete to match current state. Treat this as a historical record, not an open task list.
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add three write-side features to clawhdf5-format: (1) external link creation via `GroupBuilder`, (2) external VDS (Virtual Dataset Source) layout writes, and (3) superblock v4 read/write for page-buffering-aware files.
|
||||||
|
|
||||||
|
**Architecture:** External links reuse the existing `LinkMessage::serialize()` which already handles `LinkTarget::External` — only the `GroupBuilder` API needs wiring up. External VDS adds `write_vds_layout()` in `file_writer.rs` and `serialize_vds_mappings()` in a new `data_layout_write.rs`. Superblock v4 extends `Superblock::parse` with a new `parse_v4` branch (identical structure to v3 with an extra `page_size` field) and updates `Superblock::serialize` to optionally write v4.
|
||||||
|
|
||||||
|
**Tech Stack:** Pure Rust, no new dependencies. All changes in `crates/clawhdf5-format/`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- All code in `crates/clawhdf5-format/`.
|
||||||
|
- No new Cargo dependencies.
|
||||||
|
- External links: written as `LinkTarget::External`, readable by h5py (verified in tests).
|
||||||
|
- VDS: uses data layout version 4, class 3. Global heap at end of file.
|
||||||
|
- Superblock v4: only adds `page_size: u32` field after the v2/v3 body; checksum placement unchanged.
|
||||||
|
- Run `cargo test -p clawhdf5-format` after every task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: External link write API in GroupBuilder
|
||||||
|
|
||||||
|
**Background:** `LinkMessage::serialize()` in `link_message.rs:76–175` already handles `LinkTarget::External { filename, object_path }` (writes link_type byte = 64, then packed filename+path). What's missing is a public API in `file_writer.rs` to create external links from a `GroupBuilder`. Currently `GroupBuilder` only creates datasets and sub-groups via `create_dataset` / `create_group`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/clawhdf5-format/src/file_writer.rs` (add `GroupBuilder::add_external_link`)
|
||||||
|
- Modify: `crates/clawhdf5-format/src/lib.rs` (re-export `LinkTarget` if not already exported)
|
||||||
|
- Test: `crates/clawhdf5-format/src/file_writer.rs` (new test in `#[cfg(test)]`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `GroupBuilder::add_external_link(&mut self, name: &str, target_file: &str, target_path: &str) -> &mut Self`
|
||||||
|
|
||||||
|
- [x] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
At the bottom of the `#[cfg(test)]` block in `crates/clawhdf5-format/src/file_writer.rs`, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn external_link_write_roundtrip() {
|
||||||
|
use crate::group_v2::resolve_path_any;
|
||||||
|
use crate::link_message::{LinkMessage, LinkTarget};
|
||||||
|
use crate::message_type::MessageType;
|
||||||
|
use crate::object_header::ObjectHeader;
|
||||||
|
use crate::signature::find_signature;
|
||||||
|
use crate::superblock::Superblock;
|
||||||
|
|
||||||
|
let mut fw = FileWriter::new();
|
||||||
|
let mut grp = fw.create_group("links");
|
||||||
|
grp.add_external_link("remote_data", "other_file.h5", "/sensors/temp");
|
||||||
|
fw.add_group(grp.finish());
|
||||||
|
let bytes = fw.finish().unwrap();
|
||||||
|
|
||||||
|
let sig = find_signature(&bytes).unwrap();
|
||||||
|
let sb = Superblock::parse(&bytes, sig).unwrap();
|
||||||
|
|
||||||
|
// Navigate to /links group
|
||||||
|
let links_addr = resolve_path_any(&bytes, &sb, "links").unwrap();
|
||||||
|
let links_oh = ObjectHeader::parse(
|
||||||
|
&bytes, links_addr as usize, sb.offset_size, sb.length_size,
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
// Find the link message for "remote_data"
|
||||||
|
let link_msg = links_oh.messages.iter()
|
||||||
|
.filter(|m| m.msg_type == MessageType::Link)
|
||||||
|
.find_map(|m| {
|
||||||
|
let lm = LinkMessage::parse(&m.data, sb.offset_size).ok()?;
|
||||||
|
if lm.name == "remote_data" { Some(lm) } else { None }
|
||||||
|
})
|
||||||
|
.expect("external link message not found");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
link_msg.link_target,
|
||||||
|
LinkTarget::External {
|
||||||
|
filename: "other_file.h5".into(),
|
||||||
|
object_path: "/sensors/temp".into(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format external_link_write_roundtrip 2>&1 | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: compile error — `add_external_link` not found.
|
||||||
|
|
||||||
|
- [x] **Step 3: Find GroupBuilder in file_writer.rs and add the method**
|
||||||
|
|
||||||
|
Locate `GroupBuilder` in `crates/clawhdf5-format/src/file_writer.rs`. It tracks its items as a `Vec` of internal builders. Add a field for external links and the method:
|
||||||
|
|
||||||
|
First, locate the `GroupBuilder` struct definition and add a field:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct GroupBuilder {
|
||||||
|
name: String,
|
||||||
|
datasets: Vec<DatasetBuilder>,
|
||||||
|
groups: Vec<FinishedGroup>,
|
||||||
|
external_links: Vec<(String, String, String)>, // (name, filename, object_path)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `GroupBuilder::new()` (or equivalent constructor) to initialize `external_links: Vec::new()`.
|
||||||
|
|
||||||
|
Add the public method immediately after the existing `create_dataset`/`create_group` methods:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Add a link in this group that points to an object in another HDF5 file.
|
||||||
|
///
|
||||||
|
/// `name` is the link name within this group.
|
||||||
|
/// `target_file` is the relative or absolute path to the target .h5 file.
|
||||||
|
/// `target_path` is the HDF5 path of the object within the target 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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Wire external links into the group serialization**
|
||||||
|
|
||||||
|
Find where the `GroupBuilder` emits `LinkMessage` bytes during `finish()` / `build_group()`. For each external link, emit a `LinkMessage` with `LinkTarget::External`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::link_message::{LinkMessage, LinkTarget};
|
||||||
|
use crate::datatype::CharacterSet;
|
||||||
|
|
||||||
|
// Inside the loop/block that serializes links:
|
||||||
|
for (link_name, filename, object_path) in &self.external_links {
|
||||||
|
let msg = LinkMessage {
|
||||||
|
name: link_name.clone(),
|
||||||
|
link_target: LinkTarget::External {
|
||||||
|
filename: filename.clone(),
|
||||||
|
object_path: object_path.clone(),
|
||||||
|
},
|
||||||
|
creation_order: None,
|
||||||
|
charset: CharacterSet::Utf8,
|
||||||
|
};
|
||||||
|
let msg_bytes = msg.serialize(offset_size);
|
||||||
|
// Emit as a Link message (MessageType::Link = 0x0006) into the object header
|
||||||
|
emit_message(&mut oh_buf, MessageType::Link, &msg_bytes);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Follow the exact pattern used for hard links and soft links in the same codebase — find where hard-link `LinkMessage` bytes are pushed and add the external links in the same loop.)
|
||||||
|
|
||||||
|
- [x] **Step 5: Run the failing test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format external_link_write_roundtrip 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [x] **Step 6: Run full suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
- [x] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/clawhdf5-format/src/file_writer.rs
|
||||||
|
git commit -m "feat: add GroupBuilder::add_external_link for writing cross-file HDF5 links"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: VDS mapping serialization helper
|
||||||
|
|
||||||
|
**Background:** Reading VDS mappings from a global heap object is done by `parse_vds_mappings()` in `data_layout.rs:70–155`. Writing the inverse — serializing a `Vec<VdsMapping>` into the same binary layout — does not exist. This task creates `serialize_vds_mappings()`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `crates/clawhdf5-format/src/data_layout_write.rs`
|
||||||
|
- Modify: `crates/clawhdf5-format/src/lib.rs` (declare module)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `VdsMapping { source_file_name: String, source_dataset_name: String, source_selection: Vec<u8>, virtual_selection: Vec<u8> }` (existing struct from `data_layout.rs`).
|
||||||
|
- Produces: `pub fn serialize_vds_mappings(mappings: &[VdsMapping], length_size: u8) -> Vec<u8>`
|
||||||
|
|
||||||
|
**Binary layout (from `data_layout.rs:72–91` doc comment):**
|
||||||
|
```
|
||||||
|
version: u8 (0 = external file, 1 = same-file marker)
|
||||||
|
nused: length_size bytes (number of mappings)
|
||||||
|
for each mapping:
|
||||||
|
if version==0: source_file_name (null-terminated)
|
||||||
|
else: marker byte (0xFF or similar; same-file means empty filename)
|
||||||
|
source_dataset_name: null-terminated string
|
||||||
|
source_selection: length(length_size) + bytes
|
||||||
|
virtual_selection: length(length_size) + bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `crates/clawhdf5-format/src/data_layout_write.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
//! Write-side helpers for VDS (Virtual Dataset Source) mapping serialization.
|
||||||
|
|
||||||
|
use crate::data_layout::{parse_vds_mappings, VdsMapping};
|
||||||
|
use crate::error::FormatError;
|
||||||
|
|
||||||
|
/// Serialize a slice of VDS mappings into the global-heap object byte format.
|
||||||
|
///
|
||||||
|
/// The output can be stored directly in a global heap object and referenced
|
||||||
|
/// from a Data Layout v4 class=3 (Virtual) message.
|
||||||
|
pub fn serialize_vds_mappings(mappings: &[VdsMapping], length_size: u8) -> Vec<u8> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
|
||||||
|
// Determine if all sources are same-file (empty source_file_name)
|
||||||
|
let has_external = mappings.iter().any(|m| !m.source_file_name.is_empty());
|
||||||
|
let version: u8 = if has_external { 0 } else { 1 };
|
||||||
|
buf.push(version);
|
||||||
|
|
||||||
|
// nused: number of mappings
|
||||||
|
write_length(&mut buf, mappings.len() as u64, length_size);
|
||||||
|
|
||||||
|
for m in mappings {
|
||||||
|
if version == 0 {
|
||||||
|
// External: null-terminated filename
|
||||||
|
buf.extend_from_slice(m.source_file_name.as_bytes());
|
||||||
|
buf.push(0);
|
||||||
|
} else {
|
||||||
|
// Same-file: marker byte (0x00, which parse_vds_mappings treats as empty)
|
||||||
|
buf.push(0);
|
||||||
|
}
|
||||||
|
// source dataset name: null-terminated
|
||||||
|
buf.extend_from_slice(m.source_dataset_name.as_bytes());
|
||||||
|
buf.push(0);
|
||||||
|
// source selection: length + bytes
|
||||||
|
write_length(&mut buf, m.source_selection.len() as u64, length_size);
|
||||||
|
buf.extend_from_slice(&m.source_selection);
|
||||||
|
// virtual selection: length + bytes
|
||||||
|
write_length(&mut buf, m.virtual_selection.len() as u64, length_size);
|
||||||
|
buf.extend_from_slice(&m.virtual_selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
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()),
|
||||||
|
8 => buf.extend_from_slice(&val.to_le_bytes()),
|
||||||
|
_ => buf.extend_from_slice(&val.to_le_bytes()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn all_sel() -> Vec<u8> {
|
||||||
|
// Minimal H5S ALL selection bytes: type=3 (ALL), version=1, flags=0, unused*4
|
||||||
|
let mut v = Vec::new();
|
||||||
|
v.extend_from_slice(&3u32.to_le_bytes()); // type = H5S_SEL_ALL
|
||||||
|
v.push(1); // version
|
||||||
|
v.push(0); // flags
|
||||||
|
v.extend_from_slice(&[0u8; 4]); // unused
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_same_file_two_mappings() {
|
||||||
|
let sel = all_sel();
|
||||||
|
let mappings = vec![
|
||||||
|
VdsMapping {
|
||||||
|
source_file_name: String::new(),
|
||||||
|
source_dataset_name: "/src_a".into(),
|
||||||
|
source_selection: sel.clone(),
|
||||||
|
virtual_selection: sel.clone(),
|
||||||
|
},
|
||||||
|
VdsMapping {
|
||||||
|
source_file_name: String::new(),
|
||||||
|
source_dataset_name: "/src_b".into(),
|
||||||
|
source_selection: sel.clone(),
|
||||||
|
virtual_selection: sel.clone(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let bytes = serialize_vds_mappings(&mappings, 8);
|
||||||
|
let parsed = parse_vds_mappings(&bytes, 8).unwrap();
|
||||||
|
assert_eq!(parsed.len(), 2);
|
||||||
|
assert_eq!(parsed[0].source_dataset_name, "/src_a");
|
||||||
|
assert_eq!(parsed[1].source_dataset_name, "/src_b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_external_file_mapping() {
|
||||||
|
let sel = all_sel();
|
||||||
|
let mappings = vec![VdsMapping {
|
||||||
|
source_file_name: "source.h5".into(),
|
||||||
|
source_dataset_name: "/data".into(),
|
||||||
|
source_selection: sel.clone(),
|
||||||
|
virtual_selection: sel.clone(),
|
||||||
|
}];
|
||||||
|
let bytes = serialize_vds_mappings(&mappings, 8);
|
||||||
|
let parsed = parse_vds_mappings(&bytes, 8).unwrap();
|
||||||
|
assert_eq!(parsed.len(), 1);
|
||||||
|
assert_eq!(parsed[0].source_file_name, "source.h5");
|
||||||
|
assert_eq!(parsed[0].source_dataset_name, "/data");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_mappings_roundtrip() {
|
||||||
|
let bytes = serialize_vds_mappings(&[], 8);
|
||||||
|
let parsed = parse_vds_mappings(&bytes, 8).unwrap();
|
||||||
|
assert!(parsed.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run the failing tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format roundtrip_same_file_two_mappings roundtrip_external_file_mapping 2>&1 | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: compile errors (module not declared).
|
||||||
|
|
||||||
|
- [x] **Step 3: Declare module in lib.rs**
|
||||||
|
|
||||||
|
In `crates/clawhdf5-format/src/lib.rs`, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod data_layout_write;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Run tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format data_layout_write 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all 3 tests PASS. If `parse_vds_mappings` expects a slightly different format for the version byte or the marker byte, adjust `serialize_vds_mappings` to match what the parser consumes (read `data_layout.rs:92–155` carefully to align).
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/clawhdf5-format/src/data_layout_write.rs \
|
||||||
|
crates/clawhdf5-format/src/lib.rs
|
||||||
|
git commit -m "feat: add serialize_vds_mappings for writing VDS global-heap objects"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: FileWriter API for virtual datasets
|
||||||
|
|
||||||
|
**Background:** This task wires `serialize_vds_mappings()` into the `FileWriter` flow so callers can create a virtual dataset. It adds a new `DatasetBuilder` method and the corresponding serialization of a Data Layout v4 class=3 message.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/clawhdf5-format/src/file_writer.rs` (add `with_virtual_sources`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `DatasetBuilder::with_virtual_sources(mappings: Vec<VdsMapping>) -> &mut Self`
|
||||||
|
|
||||||
|
**Binary — Data Layout v4 class=3 (Virtual):**
|
||||||
|
```
|
||||||
|
version(1)=4 class(1)=3
|
||||||
|
global_heap_address(offset_size) global_heap_index(4)
|
||||||
|
```
|
||||||
|
The global heap object holds the `serialize_vds_mappings()` output. The `global_heap_address` is the address of the global heap collection; `global_heap_index` is the 1-based object index within it. Use index=1 for the first (and only) VDS object.
|
||||||
|
|
||||||
|
- [x] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
In `crates/clawhdf5-format/src/file_writer.rs` `#[cfg(test)]` block, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn virtual_dataset_write_roundtrip() {
|
||||||
|
use crate::data_layout::DataLayout;
|
||||||
|
use crate::data_layout::{VdsMapping, parse_vds_mappings};
|
||||||
|
use crate::message_type::MessageType;
|
||||||
|
use crate::object_header::ObjectHeader;
|
||||||
|
use crate::signature::find_signature;
|
||||||
|
use crate::superblock::Superblock;
|
||||||
|
|
||||||
|
// Minimal ALL-selection bytes (same as in data_layout_write tests)
|
||||||
|
let sel: Vec<u8> = {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
v.extend_from_slice(&3u32.to_le_bytes()); // H5S_SEL_ALL
|
||||||
|
v.push(1); v.push(0);
|
||||||
|
v.extend_from_slice(&[0u8; 4]);
|
||||||
|
v
|
||||||
|
};
|
||||||
|
|
||||||
|
let mappings = vec![VdsMapping {
|
||||||
|
source_file_name: "src.h5".into(),
|
||||||
|
source_dataset_name: "/raw".into(),
|
||||||
|
source_selection: sel.clone(),
|
||||||
|
virtual_selection: sel.clone(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
let mut fw = FileWriter::new();
|
||||||
|
fw.create_dataset("virtual_ds")
|
||||||
|
.with_virtual_sources(mappings);
|
||||||
|
let bytes = fw.finish().unwrap();
|
||||||
|
|
||||||
|
// Parse back
|
||||||
|
let sig = find_signature(&bytes).unwrap();
|
||||||
|
let sb = Superblock::parse(&bytes, sig).unwrap();
|
||||||
|
let root_oh = ObjectHeader::parse(
|
||||||
|
&bytes, sb.root_group_address as usize, sb.offset_size, sb.length_size,
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
// Find the dataset via group traversal, then get its DataLayout message
|
||||||
|
use crate::group_v2::resolve_path_any;
|
||||||
|
let ds_addr = resolve_path_any(&bytes, &sb, "virtual_ds").unwrap();
|
||||||
|
let ds_oh = ObjectHeader::parse(
|
||||||
|
&bytes, ds_addr as usize, sb.offset_size, sb.length_size,
|
||||||
|
).unwrap();
|
||||||
|
let dl_msg = ds_oh.messages.iter()
|
||||||
|
.find(|m| m.msg_type == MessageType::DataLayout)
|
||||||
|
.expect("DataLayout message missing");
|
||||||
|
|
||||||
|
let layout = DataLayout::parse(&dl_msg.data, sb.offset_size, sb.length_size).unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(layout, DataLayout::Virtual { .. }),
|
||||||
|
"expected Virtual layout, got {layout:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format virtual_dataset_write_roundtrip 2>&1 | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: compile error — `with_virtual_sources` not found.
|
||||||
|
|
||||||
|
- [x] **Step 3: Add with_virtual_sources to DatasetBuilder**
|
||||||
|
|
||||||
|
Find `DatasetBuilder` in `file_writer.rs`. Add a field `virtual_sources: Option<Vec<VdsMapping>>` and the method:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::data_layout::VdsMapping;
|
||||||
|
|
||||||
|
// In DatasetBuilder struct:
|
||||||
|
virtual_sources: Option<Vec<VdsMapping>>,
|
||||||
|
|
||||||
|
// In DatasetBuilder impl:
|
||||||
|
pub fn with_virtual_sources(&mut self, mappings: Vec<VdsMapping>) -> &mut Self {
|
||||||
|
self.virtual_sources = Some(mappings);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Serialize the virtual data layout**
|
||||||
|
|
||||||
|
In the `DatasetBuilder::build()` or equivalent finish method, add a branch for virtual datasets:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::data_layout_write::serialize_vds_mappings;
|
||||||
|
|
||||||
|
// Where the DataLayout message bytes are generated:
|
||||||
|
let layout_bytes = if let Some(mappings) = &self.virtual_sources {
|
||||||
|
// Serialize VDS mappings into a global heap object
|
||||||
|
let heap_data = serialize_vds_mappings(mappings, length_size);
|
||||||
|
let (heap_addr, heap_idx) = write_global_heap_object(output_buf, &heap_data);
|
||||||
|
// Data Layout v4 class=3 (Virtual): version(1)=4, class(1)=3, addr(offset_size), idx(4)
|
||||||
|
let mut dl = Vec::new();
|
||||||
|
dl.push(4u8); // version
|
||||||
|
dl.push(3u8); // class = Virtual
|
||||||
|
write_offset_val(&mut dl, heap_addr, offset_size);
|
||||||
|
dl.extend_from_slice(&(heap_idx as u32).to_le_bytes());
|
||||||
|
dl
|
||||||
|
} else {
|
||||||
|
// existing layout code (contiguous/compact/chunked)
|
||||||
|
build_existing_layout(...)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement `write_global_heap_object` as a helper that appends a minimal global heap collection to the output buffer and returns `(address, object_index)`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Append a single-object global heap collection to `buf` and return
|
||||||
|
/// (collection_address, object_index=1).
|
||||||
|
fn write_global_heap_object(buf: &mut Vec<u8>, data: &[u8]) -> (u64, usize) {
|
||||||
|
let addr = buf.len() as u64;
|
||||||
|
// Global Heap Collection header: sig(4) + version(1) + reserved(3) + collection_size(8)
|
||||||
|
// Object: index(2) + ref_count(2) + reserved(4) + data_size(8) + data + padding
|
||||||
|
let obj_size = data.len();
|
||||||
|
let padded = (obj_size + 7) & !7;
|
||||||
|
let collection_size = 16 + 16 + padded + 8; // header + one obj header + data + sentinel
|
||||||
|
buf.extend_from_slice(b"GCOL"); // signature
|
||||||
|
buf.push(1); // version
|
||||||
|
buf.extend_from_slice(&[0u8; 3]); // reserved
|
||||||
|
buf.extend_from_slice(&(collection_size as u64).to_le_bytes());
|
||||||
|
// Object 1
|
||||||
|
buf.extend_from_slice(&1u16.to_le_bytes()); // index
|
||||||
|
buf.extend_from_slice(&1u16.to_le_bytes()); // ref_count
|
||||||
|
buf.extend_from_slice(&[0u8; 4]); // reserved
|
||||||
|
buf.extend_from_slice(&(obj_size as u64).to_le_bytes());
|
||||||
|
buf.extend_from_slice(data);
|
||||||
|
// Pad to 8-byte boundary
|
||||||
|
let pad = padded - obj_size;
|
||||||
|
buf.extend_from_slice(&vec![0u8; pad]);
|
||||||
|
// Sentinel object (index=0)
|
||||||
|
buf.extend_from_slice(&[0u8; 8]); // index=0 + ref_count + reserved
|
||||||
|
buf.extend_from_slice(&0u64.to_le_bytes()); // size=0
|
||||||
|
(addr, 1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: Run the test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format virtual_dataset_write_roundtrip 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS (or iterate on the global heap format until `parse_vds_mappings` reads back the mappings).
|
||||||
|
|
||||||
|
- [x] **Step 6: Run full suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
- [x] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/clawhdf5-format/src/file_writer.rs
|
||||||
|
git commit -m "feat: add DatasetBuilder::with_virtual_sources for writing VDS data layout"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Superblock v4 read support
|
||||||
|
|
||||||
|
**Background:** `Superblock::parse()` in `superblock.rs:178–183` returns `Err(FormatError::UnsupportedVersion(v))` for any version ≥ 4. Superblock v4 (introduced with HDF5 2.x page-buffering) shares the same 12-byte header as v2/v3 (`sig + version + offset_size + length_size + consistency_flags`) and the same four address fields, but adds a `page_size: u32` field before the trailing checksum.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/clawhdf5-format/src/superblock.rs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes/produces: `Superblock` struct — add `pub page_size: Option<u32>` field.
|
||||||
|
|
||||||
|
- [x] **Step 1: Add field to Superblock struct**
|
||||||
|
|
||||||
|
In `crates/clawhdf5-format/src/superblock.rs`, add to the `Superblock` struct:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Page size for page-buffer mode (v4 only). `None` for v0–v3.
|
||||||
|
pub page_size: Option<u32>,
|
||||||
|
```
|
||||||
|
|
||||||
|
Update all existing construction sites of `Superblock { ... }` in the file (parse_v0, parse_v1, parse_v2v3) to include `page_size: None`.
|
||||||
|
|
||||||
|
- [x] **Step 2: Write the failing test**
|
||||||
|
|
||||||
|
In the `#[cfg(test)]` section of `superblock.rs`, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn parse_v4_with_page_size() {
|
||||||
|
// Superblock v4 = v2/v3 layout + page_size(4) before checksum.
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
buf.extend_from_slice(&crate::signature::HDF5_SIGNATURE);
|
||||||
|
buf.push(4); // version = 4
|
||||||
|
buf.push(8); // offset_size
|
||||||
|
buf.push(8); // length_size
|
||||||
|
buf.push(0); // consistency_flags
|
||||||
|
// base_address
|
||||||
|
buf.extend_from_slice(&0u64.to_le_bytes());
|
||||||
|
// superblock_extension_address = UNDEF
|
||||||
|
buf.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||||
|
// eof_address
|
||||||
|
buf.extend_from_slice(&512u64.to_le_bytes());
|
||||||
|
// root_group_address
|
||||||
|
buf.extend_from_slice(&96u64.to_le_bytes());
|
||||||
|
// page_size (v4 addition before checksum)
|
||||||
|
buf.extend_from_slice(&4096u32.to_le_bytes());
|
||||||
|
// checksum (4 bytes; compute with jenkins_lookup3)
|
||||||
|
let checksum = crate::checksum::jenkins_lookup3(&buf);
|
||||||
|
buf.extend_from_slice(&checksum.to_le_bytes());
|
||||||
|
|
||||||
|
let sb = Superblock::parse(&buf, 0).unwrap();
|
||||||
|
assert_eq!(sb.version, 4);
|
||||||
|
assert_eq!(sb.offset_size, 8);
|
||||||
|
assert_eq!(sb.eof_address, 512);
|
||||||
|
assert_eq!(sb.page_size, Some(4096));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Run test to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format parse_v4_with_page_size 2>&1 | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Err(UnsupportedVersion(4))` — the test fails because v4 isn't handled.
|
||||||
|
|
||||||
|
- [x] **Step 4: Add parse_v4 branch**
|
||||||
|
|
||||||
|
In `Superblock::parse()`, change:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
2 | 3 => Self::parse_v2v3(d, version),
|
||||||
|
v => Err(FormatError::UnsupportedVersion(v)),
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
2 | 3 => Self::parse_v2v3(d, version),
|
||||||
|
4 => Self::parse_v4(d),
|
||||||
|
v => Err(FormatError::UnsupportedVersion(v)),
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the implementation:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn parse_v4(d: &[u8]) -> Result<Superblock, FormatError> {
|
||||||
|
// Same as v2/v3 header, then page_size(4), then checksum(4).
|
||||||
|
ensure_len(d, 12)?;
|
||||||
|
let offset_size = d[9];
|
||||||
|
let length_size = d[10];
|
||||||
|
validate_sizes(offset_size, length_size)?;
|
||||||
|
let consistency_flags = d[11] as u32;
|
||||||
|
|
||||||
|
let os = offset_size as usize;
|
||||||
|
// 4 addresses + page_size(4) + checksum(4)
|
||||||
|
let total = 12 + 4 * os + 4 + 4;
|
||||||
|
ensure_len(d, total)?;
|
||||||
|
|
||||||
|
let mut pos = 12;
|
||||||
|
let base_address = read_offset(d, pos, offset_size)?;
|
||||||
|
pos += os;
|
||||||
|
let superblock_extension_address = read_offset(d, pos, offset_size)?;
|
||||||
|
pos += os;
|
||||||
|
let eof_address = read_offset(d, pos, offset_size)?;
|
||||||
|
pos += os;
|
||||||
|
let root_group_address = read_offset(d, pos, offset_size)?;
|
||||||
|
pos += os;
|
||||||
|
|
||||||
|
let page_size = u32::from_le_bytes([d[pos], d[pos+1], d[pos+2], d[pos+3]]);
|
||||||
|
pos += 4;
|
||||||
|
|
||||||
|
let stored_checksum = u32::from_le_bytes([d[pos], d[pos+1], d[pos+2], d[pos+3]]);
|
||||||
|
let computed = crate::checksum::jenkins_lookup3(&d[..pos]);
|
||||||
|
if stored_checksum != computed {
|
||||||
|
return Err(FormatError::ChecksumMismatch {
|
||||||
|
expected: stored_checksum,
|
||||||
|
computed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Superblock {
|
||||||
|
version: 4,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
base_address,
|
||||||
|
eof_address,
|
||||||
|
root_group_address,
|
||||||
|
group_leaf_node_k: None,
|
||||||
|
group_internal_node_k: None,
|
||||||
|
indexed_storage_internal_node_k: None,
|
||||||
|
free_space_address: None,
|
||||||
|
driver_info_address: None,
|
||||||
|
consistency_flags,
|
||||||
|
superblock_extension_address: Some(superblock_extension_address),
|
||||||
|
checksum: Some(stored_checksum),
|
||||||
|
page_size: Some(page_size),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: Run the test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format parse_v4_with_page_size 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS (verify the checksum field name matches whatever `FormatError` uses — it may be `ChecksumMismatch { expected, computed }` or similar; find it in `error.rs` and match).
|
||||||
|
|
||||||
|
- [x] **Step 6: Run full suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
- [x] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/clawhdf5-format/src/superblock.rs
|
||||||
|
git commit -m "feat: parse HDF5 superblock v4 (page-buffer mode) with page_size field"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Superblock v4 write support
|
||||||
|
|
||||||
|
**Background:** The `FileWriter` always writes a v3 superblock (hardcoded in `file_writer.rs:1291–1306`). This task adds an optional `page_size` to `FileWriter` that, when set, emits a v4 superblock.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/clawhdf5-format/src/file_writer.rs` (add `page_size` field)
|
||||||
|
- Modify: `crates/clawhdf5-format/src/superblock.rs` (`Superblock::serialize` for v4)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `FileWriter::with_page_size(page_size: u32) -> &mut Self`
|
||||||
|
|
||||||
|
- [x] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
In the `#[cfg(test)]` block of `file_writer.rs`, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn file_writer_v4_superblock() {
|
||||||
|
use crate::signature::find_signature;
|
||||||
|
use crate::superblock::Superblock;
|
||||||
|
|
||||||
|
let mut fw = FileWriter::new();
|
||||||
|
fw.with_page_size(4096);
|
||||||
|
fw.create_dataset("data").with_f64_data(&[1.0, 2.0]);
|
||||||
|
let bytes = fw.finish().unwrap();
|
||||||
|
|
||||||
|
let sig = find_signature(&bytes).unwrap();
|
||||||
|
let sb = Superblock::parse(&bytes, sig).unwrap();
|
||||||
|
assert_eq!(sb.version, 4, "expected superblock v4");
|
||||||
|
assert_eq!(sb.page_size, Some(4096));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format file_writer_v4_superblock 2>&1 | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: compile error — `with_page_size` not found.
|
||||||
|
|
||||||
|
- [x] **Step 3: Add page_size field to FileWriter**
|
||||||
|
|
||||||
|
In `FileWriter` struct definition, add `page_size: Option<u32>`.
|
||||||
|
In `FileWriter::new()`, add `page_size: None`.
|
||||||
|
Add method:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn with_page_size(&mut self, page_size: u32) -> &mut Self {
|
||||||
|
self.page_size = Some(page_size);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Update Superblock::serialize for v4**
|
||||||
|
|
||||||
|
In `crates/clawhdf5-format/src/superblock.rs`, the `serialize()` method currently hardcodes v2/v3 format. Update it to emit v4 when `self.version == 4` and `self.page_size.is_some()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn serialize(&self) -> Vec<u8> {
|
||||||
|
let mut buf = Vec::with_capacity(60);
|
||||||
|
buf.extend_from_slice(&HDF5_SIGNATURE);
|
||||||
|
buf.push(self.version);
|
||||||
|
buf.push(self.offset_size);
|
||||||
|
buf.push(self.length_size);
|
||||||
|
buf.push(self.consistency_flags as u8);
|
||||||
|
Self::write_offset(&mut buf, self.base_address, self.offset_size);
|
||||||
|
let ext_addr = self.superblock_extension_address.unwrap_or(u64::MAX);
|
||||||
|
Self::write_offset(&mut buf, ext_addr, self.offset_size);
|
||||||
|
Self::write_offset(&mut buf, self.eof_address, self.offset_size);
|
||||||
|
Self::write_offset(&mut buf, self.root_group_address, self.offset_size);
|
||||||
|
if self.version >= 4 {
|
||||||
|
let ps = self.page_size.unwrap_or(0);
|
||||||
|
buf.extend_from_slice(&ps.to_le_bytes());
|
||||||
|
}
|
||||||
|
let checksum = crate::checksum::jenkins_lookup3(&buf);
|
||||||
|
buf.extend_from_slice(&checksum.to_le_bytes());
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: Wire page_size into FileWriter::finish()**
|
||||||
|
|
||||||
|
In `file_writer.rs:finish()`, where the `Superblock` is constructed (around line 1291), change:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let sb = Superblock {
|
||||||
|
version: if self.page_size.is_some() { 4 } else { 3 },
|
||||||
|
// ... existing fields ...
|
||||||
|
page_size: self.page_size,
|
||||||
|
// ... rest of fields unchanged ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 6: Run the test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format file_writer_v4_superblock 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [x] **Step 7: Run full suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-format 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass (v3 serialize() must be byte-identical to before — add a regression test if needed).
|
||||||
|
|
||||||
|
- [x] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/clawhdf5-format/src/file_writer.rs \
|
||||||
|
crates/clawhdf5-format/src/superblock.rs
|
||||||
|
git commit -m "feat: write HDF5 superblock v4 when page_size is configured"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all format tests
|
||||||
|
cargo test -p clawhdf5-format 2>&1 | tail -10
|
||||||
|
|
||||||
|
# Specifically verify new features
|
||||||
|
cargo test -p clawhdf5-format external_link_write_roundtrip 2>&1
|
||||||
|
cargo test -p clawhdf5-format data_layout_write 2>&1
|
||||||
|
cargo test -p clawhdf5-format virtual_dataset_write_roundtrip 2>&1
|
||||||
|
cargo test -p clawhdf5-format parse_v4_with_page_size 2>&1
|
||||||
|
cargo test -p clawhdf5-format file_writer_v4_superblock 2>&1
|
||||||
|
|
||||||
|
# Regression: v3 superblock still round-trips
|
||||||
|
cargo test -p clawhdf5-format write_superblock 2>&1
|
||||||
|
```
|
||||||
@@ -0,0 +1,759 @@
|
|||||||
|
# MPI-IO VOL Backend Implementation Plan
|
||||||
|
|
||||||
|
> **Status (2026-08-03):** Implemented — shipped in commit `d6c4d4f` (2026-06-30), with FFI/constant fixes in `cb0b0e9`/`e91f7fc`. This doc was authored 2026-06-29 as the pre-work plan and committed to the repo retroactively on 2026-08-03; checkboxes below have been marked complete to match. Treat this as a historical record, not an open task list.
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add an `MpiVol` backend to `clawhdf5-io` that implements `VirtualObjectLayer` with `VolCapability::ParallelIO`, enabling collective MPI-IO reads and writes against HDF5 files — the same I/O pattern used by h5bench parallel workloads.
|
||||||
|
|
||||||
|
**Architecture:** A new `crates/clawhdf5-io/src/mpi_vol.rs` module implements `VirtualObjectLayer` using the `rsmpi` crate for MPI bindings. Reads distribute file chunks across MPI ranks via `MPI_File_read_at` collective; writes gather chunk contributions from all ranks and commit atomically. The `mpi-io` feature flag keeps MPI an optional dependency — without it, the file does not compile in, maintaining the zero-required-dependency promise.
|
||||||
|
|
||||||
|
**Tech Stack:** `rsmpi = "0.8"` (or latest; the safe Rust MPI binding), `mpi-io` feature flag in `clawhdf5-io`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- All changes in `crates/clawhdf5-io/`.
|
||||||
|
- `mpi-io` feature is disabled by default; `cargo test -p clawhdf5-io` without features must still pass.
|
||||||
|
- `MpiVol` must not link MPI unless `mpi-io` feature is active.
|
||||||
|
- Tests that require an actual MPI environment are gated with `#[cfg(feature = "mpi-io")]` and ignored by default CI (no `#[ignore]`; they fail to compile without the feature).
|
||||||
|
- Run `cargo test -p clawhdf5-io` after every task.
|
||||||
|
- Run `cargo check -p clawhdf5-io --features mpi-io` to validate the feature-enabled path without needing MPI installed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add mpi-io feature and MpiVol skeleton
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/clawhdf5-io/Cargo.toml`
|
||||||
|
- Create: `crates/clawhdf5-io/src/mpi_vol.rs`
|
||||||
|
- Modify: `crates/clawhdf5-io/src/lib.rs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `pub struct MpiVol` (implements `VirtualObjectLayer`)
|
||||||
|
- `MpiVol::new(comm: impl Into<MpiComm>) -> Self` — wraps an MPI communicator
|
||||||
|
- `MpiVol::new_world() -> Self` — convenience for `MPI_COMM_WORLD`
|
||||||
|
|
||||||
|
- [x] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
Create `crates/clawhdf5-io/src/mpi_vol.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
//! MPI-IO VOL connector for parallel HDF5 reads and writes.
|
||||||
|
//!
|
||||||
|
//! Enable with the `mpi-io` feature: `cargo build --features mpi-io`.
|
||||||
|
//!
|
||||||
|
//! # Parallelism model
|
||||||
|
//!
|
||||||
|
//! All ranks open the same file path. Reads are collective: the root rank
|
||||||
|
//! dispatches chunk byte ranges; each rank fetches its portion via
|
||||||
|
//! `MPI_File_read_at`. Writes are collective: each rank submits its chunk
|
||||||
|
//! contribution; the root commits the merged result atomically.
|
||||||
|
|
||||||
|
use crate::vol::{VolCapability, VolError, VirtualObjectLayer};
|
||||||
|
|
||||||
|
#[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")]
|
||||||
|
universe: mpi::environment::Universe,
|
||||||
|
#[cfg(not(feature = "mpi-io"))]
|
||||||
|
_placeholder: (),
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mpi::traits::*;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
let world = vol.universe.world();
|
||||||
|
let rank = world.rank();
|
||||||
|
|
||||||
|
// All ranks attempt the read; root broadcasts the result.
|
||||||
|
// For true MPI-IO, use MPI_File_open + MPI_File_read_at_all here.
|
||||||
|
let raw_data: Vec<u8>;
|
||||||
|
let mut len_buf = [0usize; 1];
|
||||||
|
|
||||||
|
if rank == 0 {
|
||||||
|
let bytes = std::fs::read(location)
|
||||||
|
.map_err(|e| VolError::Io(e))?;
|
||||||
|
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 mpi::traits::*;
|
||||||
|
use clawhdf5_format::file_writer::FileWriter as FmtWriter;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Gather all data at root
|
||||||
|
let total: usize = if world.rank() == 0 {
|
||||||
|
all_lens.iter().sum()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Root collects all contributions and writes
|
||||||
|
if world.rank() == 0 {
|
||||||
|
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() {
|
||||||
|
// Even without feature, the struct can be inspected via the default stub.
|
||||||
|
// The capabilities list is compile-time constant so test it directly.
|
||||||
|
let caps = vec![
|
||||||
|
VolCapability::ReadData,
|
||||||
|
VolCapability::WriteData,
|
||||||
|
VolCapability::ListObjects,
|
||||||
|
VolCapability::ChunkedStorage,
|
||||||
|
VolCapability::ParallelIO,
|
||||||
|
];
|
||||||
|
assert!(caps.contains(&VolCapability::ParallelIO));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rank_and_size_stub_values() {
|
||||||
|
#[cfg(not(feature = "mpi-io"))]
|
||||||
|
{
|
||||||
|
// The constructor itself returns Err without the feature,
|
||||||
|
// so we can't instantiate MpiVol here. Verify the error message.
|
||||||
|
let e = MpiVol::new_world().unwrap_err();
|
||||||
|
assert!(e.to_string().contains("mpi-io"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-io mpi_vol 2>&1 | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: compile error (module not declared). That's the expected failure.
|
||||||
|
|
||||||
|
- [x] **Step 3: Add Cargo.toml feature and rsmpi dependency**
|
||||||
|
|
||||||
|
In `crates/clawhdf5-io/Cargo.toml`, add to `[dependencies]`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
mpi = { version = "0.8", optional = true }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `[features]`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
mpi-io = ["mpi"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Declare module in lib.rs**
|
||||||
|
|
||||||
|
In `crates/clawhdf5-io/src/lib.rs`, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod mpi_vol;
|
||||||
|
pub use mpi_vol::MpiVol;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: Run tests without mpi-io feature**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-io 2>&1 | tail -15
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `mpi_vol_no_feature_returns_unsupported` and `mpi_vol_capabilities_include_parallel_io` PASS.
|
||||||
|
|
||||||
|
- [x] **Step 6: Check compilation with mpi-io feature (requires MPI headers)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install MPI if needed: sudo apt install libopenmpi-dev
|
||||||
|
cargo check -p clawhdf5-io --features mpi-io 2>&1 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: clean compile (warnings OK; errors not OK).
|
||||||
|
|
||||||
|
- [x] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/clawhdf5-io/Cargo.toml \
|
||||||
|
crates/clawhdf5-io/src/mpi_vol.rs \
|
||||||
|
crates/clawhdf5-io/src/lib.rs
|
||||||
|
git commit -m "feat: add MpiVol VOL backend with collective MPI-IO (mpi-io feature)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: MPI-IO collective read integration test
|
||||||
|
|
||||||
|
**Background:** This test requires an MPI runtime (`mpirun`). It is gated by the `mpi-io` feature and validates that all MPI ranks receive identical data after a collective read.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/clawhdf5-io/src/mpi_vol.rs` (add integration test)
|
||||||
|
|
||||||
|
- [x] **Step 1: Add the integration test**
|
||||||
|
|
||||||
|
Inside the `#[cfg(test)]` block, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
#[cfg(feature = "mpi-io")]
|
||||||
|
fn collective_read_all_ranks_get_same_data() {
|
||||||
|
use crate::vol::VirtualObjectLayer;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// Write a reference file using FileWriter (no MPI needed)
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each rank reads via MpiVol and should get the same bytes
|
||||||
|
let mut vol = MpiVol::new_world().expect("MPI init failed");
|
||||||
|
vol.open(path.to_str().unwrap()).unwrap();
|
||||||
|
let data = vol.read_dataset("temperature").unwrap();
|
||||||
|
|
||||||
|
// 5 f64 values = 40 bytes
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `Cargo.toml` dev-dependencies:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
tempfile = "3"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run without MPI feature (should compile-skip)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-io 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass; `collective_read_all_ranks_get_same_data` is not compiled.
|
||||||
|
|
||||||
|
- [x] **Step 3: Run with MPI feature (requires mpirun)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requires: sudo apt install libopenmpi-dev openmpi-bin
|
||||||
|
# cargo test compiles, then:
|
||||||
|
mpirun -np 4 cargo test -p clawhdf5-io --features mpi-io collective_read_all_ranks_get_same_data 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all 4 ranks PASS.
|
||||||
|
|
||||||
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/clawhdf5-io/src/mpi_vol.rs \
|
||||||
|
crates/clawhdf5-io/Cargo.toml
|
||||||
|
git commit -m "feat: add MpiVol collective read integration test"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: MPI-IO collective write integration test
|
||||||
|
|
||||||
|
**Background:** Validates that N ranks each contribute a shard of a dataset; rank 0 assembles and writes the complete file.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/clawhdf5-io/src/mpi_vol.rs`
|
||||||
|
|
||||||
|
- [x] **Step 1: Add the integration test**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
#[cfg(feature = "mpi-io")]
|
||||||
|
fn collective_write_assembles_all_shards() {
|
||||||
|
use crate::vol::VirtualObjectLayer;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use mpi::traits::*;
|
||||||
|
|
||||||
|
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;
|
||||||
|
// Each rank contributes one f64 value: rank * 10.0
|
||||||
|
let shard = ((rank as f64) * 10.0f64).to_le_bytes().to_vec();
|
||||||
|
|
||||||
|
vol.write_dataset("values", &shard, &[world.size() as u64], "f64")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// All ranks verify the written file has 4 values (one per rank)
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p clawhdf5-io 2>&1 | tail -5 # no feature — should pass
|
||||||
|
mpirun -np 4 cargo test -p clawhdf5-io --features mpi-io collective_write 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/clawhdf5-io/src/mpi_vol.rs
|
||||||
|
git commit -m "feat: add MpiVol collective write integration test (4 ranks)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: MpiVol parallel benchmark binary
|
||||||
|
|
||||||
|
**Background:** Adds a benchmark binary to `clawhdf5-bench` that runs h5bench-equivalent write/read workloads using `MpiVol`. This provides the throughput numbers needed to compare clawhdf5 against standard libhdf5 + h5bench.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `crates/clawhdf5-bench/src/bin/mpi_io_bench.rs`
|
||||||
|
- Modify: `crates/clawhdf5-bench/Cargo.toml` (add `mpi-io` feature, `mpi_io_bench` binary)
|
||||||
|
|
||||||
|
**Produces:** `cargo run -p clawhdf5-bench --features mpi-io --bin mpi_io_bench -- --size 100000` outputs MB/s throughput numbers comparable to h5bench output.
|
||||||
|
|
||||||
|
- [x] **Step 1: Create the binary**
|
||||||
|
|
||||||
|
Create `crates/clawhdf5-bench/src/bin/mpi_io_bench.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
//! 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 std::time::Instant;
|
||||||
|
use mpi::traits::*;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Add to Cargo.toml**
|
||||||
|
|
||||||
|
In `crates/clawhdf5-bench/Cargo.toml`, add:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
clawhdf5-io = { path = "../clawhdf5-io", features = [] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
mpi-io = ["clawhdf5-io/mpi-io", "mpi"]
|
||||||
|
|
||||||
|
[dependencies.mpi]
|
||||||
|
version = "0.8"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "mpi_io_bench"
|
||||||
|
path = "src/bin/mpi_io_bench.rs"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Verify it compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check -p clawhdf5-bench --features mpi-io 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [x] **Step 4: Run with 4 ranks**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mpirun -np 4 cargo run --release -p clawhdf5-bench --features mpi-io --bin mpi_io_bench -- --size 1000000 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output (numbers will vary by hardware):
|
||||||
|
```
|
||||||
|
=== clawhdf5 MPI-IO Benchmark ===
|
||||||
|
Elements : 1000000
|
||||||
|
Ranks : 4
|
||||||
|
Total : 8.0 MB
|
||||||
|
Write : xxx.x MB/s
|
||||||
|
Read : xxx.x MB/s
|
||||||
|
```
|
||||||
|
|
||||||
|
Record results in `BENCHMARKS.md` under a new `## MPI-IO Parallel I/O` section.
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/clawhdf5-bench/src/bin/mpi_io_bench.rs \
|
||||||
|
crates/clawhdf5-bench/Cargo.toml
|
||||||
|
git commit -m "feat: add mpi_io_bench binary for h5bench-comparable parallel I/O throughput"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Without MPI feature — all existing tests still pass
|
||||||
|
cargo test -p clawhdf5-io 2>&1 | tail -10
|
||||||
|
|
||||||
|
# With MPI feature — compile check (requires libopenmpi-dev)
|
||||||
|
cargo check -p clawhdf5-io --features mpi-io 2>&1 | tail -5
|
||||||
|
|
||||||
|
# Integration tests (requires openmpi-bin)
|
||||||
|
mpirun -np 4 cargo test -p clawhdf5-io --features mpi-io 2>&1 | tail -20
|
||||||
|
|
||||||
|
# Benchmark (requires openmpi-bin)
|
||||||
|
mpirun -np 4 cargo run --release -p clawhdf5-bench --features mpi-io --bin mpi_io_bench -- --size 1000000 2>&1
|
||||||
|
```
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# CI check: verify rustyhdf5-format compiles under no_std (thumbv7em-none-eabihf).
|
# CI check: verify clawhdf5-format compiles under no_std (thumbv7em-none-eabihf).
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./scripts/check-nostd.sh
|
# ./scripts/check-nostd.sh
|
||||||
@@ -11,7 +11,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
TARGET="thumbv7em-none-eabihf"
|
TARGET="thumbv7em-none-eabihf"
|
||||||
|
|
||||||
echo "==> Checking no_std build for rustyhdf5-format (target: $TARGET)"
|
echo "==> Checking no_std build for clawhdf5-format (target: $TARGET)"
|
||||||
|
|
||||||
# Ensure the target is installed
|
# Ensure the target is installed
|
||||||
if ! rustup target list --installed | grep -q "$TARGET"; then
|
if ! rustup target list --installed | grep -q "$TARGET"; then
|
||||||
@@ -20,13 +20,13 @@ if ! rustup target list --installed | grep -q "$TARGET"; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Build with no default features (no std, no flate2, no sha2)
|
# Build with no default features (no std, no flate2, no sha2)
|
||||||
cargo build --target "$TARGET" -p rustyhdf5-format --no-default-features
|
cargo build --target "$TARGET" -p clawhdf5-format --no-default-features
|
||||||
|
|
||||||
echo "==> no_std build succeeded"
|
echo "==> no_std build succeeded"
|
||||||
|
|
||||||
# Also verify the default-features (std) build still works
|
# Also verify the default-features (std) build still works
|
||||||
echo "==> Checking default-features build"
|
echo "==> Checking default-features build"
|
||||||
cargo build -p rustyhdf5-format
|
cargo build -p clawhdf5-format
|
||||||
echo "==> default-features build succeeded"
|
echo "==> default-features build succeeded"
|
||||||
|
|
||||||
echo "==> All no_std checks passed"
|
echo "==> All no_std checks passed"
|
||||||
|
|||||||
+4
-4
@@ -34,16 +34,16 @@ run_step() {
|
|||||||
# 1. Format check
|
# 1. Format check
|
||||||
run_step "cargo fmt --check" cargo fmt --check
|
run_step "cargo fmt --check" cargo fmt --check
|
||||||
|
|
||||||
# 2. Clippy (exclude rustyhdf5-py which needs PyO3/Python)
|
# 2. Clippy (exclude clawhdf5-py which needs PyO3/Python)
|
||||||
run_step "cargo clippy" cargo clippy \
|
run_step "cargo clippy" cargo clippy \
|
||||||
--workspace \
|
--workspace \
|
||||||
--exclude rustyhdf5-py \
|
--exclude clawhdf5-py \
|
||||||
-- -D warnings
|
-- -D warnings
|
||||||
|
|
||||||
# 3. Tests (exclude rustyhdf5-py)
|
# 3. Tests (exclude clawhdf5-py)
|
||||||
run_step "cargo test" cargo test \
|
run_step "cargo test" cargo test \
|
||||||
--workspace \
|
--workspace \
|
||||||
--exclude rustyhdf5-py
|
--exclude clawhdf5-py
|
||||||
|
|
||||||
# 4. no_std check
|
# 4. no_std check
|
||||||
run_step "check-nostd.sh" "$SCRIPT_DIR/check-nostd.sh"
|
run_step "check-nostd.sh" "$SCRIPT_DIR/check-nostd.sh"
|
||||||
|
|||||||
Reference in New Issue
Block a user