Case Study 2: Designing a Chunked, Compressed HDF5 Archive
"Choosing the chunk shape is choosing which reads are fast and which are slow. Decide on purpose." — the operational lesson every large-simulation group learns once
Executive Summary
Case Study 1 audited a mute text pipeline and chose a self-describing target. Now we build the scalable output — and go one level deeper than "just write NetCDF." The simulation here produces a long time series of 2D fields and needs an archive that is compact on disk, fast to read the way the group actually reads it, and organized so a human can navigate a thousand snapshots. That points at HDF5, where we control the hierarchy, the chunk shape, and the compression directly. This case study designs the group layout, builds a per-snapshot writer with a chunking-and-compression property list, then optimizes the two decisions that dominate performance — chunk shape and compression level — against the group's real access pattern, and estimates the payoff. Every design choice is made on purpose, with its tradeoff stated.
Skills applied - Designing an HDF5 group hierarchy for a time series (§25.3) - Building a chunked, deflate-compressed dataset writer with a property list (§25.3) - Choosing a chunk shape from the read/write access pattern — the core optimization (§25.3) - Trading compression level against write speed (§25.3) - Attaching run metadata as attributes for reproducibility (§25.3, §25.5) - Estimating and validating the storage payoff (§25.1, §25.4)
Background
The solver evolves a $1024 \times 1024$ temperature field and saves a snapshot every few steps — call it 1,000 snapshots over a run. The group's analysis habits, gathered by asking (never guess an access pattern), are: usually load one whole snapshot to make a figure; sometimes extract a small region of interest across many snapshots to plot its time history; rarely touch everything at once. Two requirements fall out immediately — whole-snapshot reads must be fast (the common case), and the archive must be small enough to keep online. HDF5 serves both if we choose its parameters deliberately.
Phase 1 — Design the Hierarchy
HDF5's groups let the file document its own organization. We use a shallow, predictable layout — a metadata group and a fields group, one dataset per snapshot under a zero-padded name so they sort:
/ (root)
/meta (group: run-level attributes)
nx, ny, dx, dy (attributes)
alpha, source (attributes)
/fields (group: the time series)
/fields/step_000000 (dataset: 1024x1024 double, chunked+compressed)
/fields/step_000001
...
/fields/step_000999
The design choices, each with a reason: zero-padded names (step_000042) so the thousand datasets
sort lexically the way they sort numerically; a separate /meta group so run parameters live in one
predictable place rather than being repeated on every dataset; and one dataset per snapshot rather than
a single 3D (x, y, time) dataset. The 3D-with-extendible-time design is also valid and more compact for
pure whole-array streaming, but it needs extendible dataspaces and hyperslab writes (h5sselect_hyperslab_f,
h5dset_extent_f) — more API surface, and the per-snapshot layout is simpler to write, browse, and
partially recover if a run aborts. We note the alternative and choose simplicity on purpose.
Phase 2 — Build the Writer
The per-snapshot writer creates a chunked, compressed dataset under /fields. It is the
example-03 pattern, generalized to take a step number and build the dataset path. (Requires HDF5
installed; not run here.)
subroutine write_snapshot(file_id, u, step, chunk, level)
use, intrinsic :: iso_fortran_env, only: dp => real64
use hdf5
integer(hid_t), intent(in) :: file_id ! the open /fields group or file
real(dp), intent(in) :: u(:,:)
integer, intent(in) :: step, chunk(2), level
integer(hid_t) :: space_id, dset_id, dcpl
integer(hsize_t) :: dims(2), chunk_dims(2)
character(len=16) :: name
integer :: hdferr
dims = [int(size(u,1), hsize_t), int(size(u,2), hsize_t)]
chunk_dims = [int(chunk(1), hsize_t), int(chunk(2), hsize_t)]
write(name, '(a, i6.6)') 'step_', step ! step_000042 (internal-file write, Ch.12)
call h5screate_simple_f(2, dims, space_id, hdferr)
call h5pcreate_f(H5P_DATASET_CREATE_F, dcpl, hdferr)
call h5pset_chunk_f(dcpl, 2, chunk_dims, hdferr) ! chosen in Phase 3
call h5pset_shuffle_f(dcpl, hdferr) ! reorder bytes to help deflate
call h5pset_deflate_f(dcpl, level, hdferr) ! chosen in Phase 4
call h5dcreate_f(file_id, trim(name), H5T_NATIVE_DOUBLE, space_id, &
dset_id, hdferr, dcpl)
call h5dwrite_f(dset_id, H5T_NATIVE_DOUBLE, u, dims, hdferr)
call h5pclose_f(dcpl, hdferr) ! close every handle
call h5dclose_f(dset_id, hdferr)
call h5sclose_f(space_id, hdferr)
end subroutine write_snapshot
Two design decisions are already visible. The chunk shape and compression level are parameters, not
hard-coded — because they are exactly what we tune in the next two phases. And h5pset_shuffle_f runs
before deflate: shuffle regroups the bytes of the values (all the first bytes, then all the second, …),
which makes the near-identical high-order bytes of a smooth field line up and compress far better. The
compile command is h5fc -std=f2018 -Wall ... -o writer.
Phase 3 — Optimize the Chunk Shape
This is the decision the epigraph warns about. A chunk is the atomic unit of I/O and compression: to read any element, HDF5 reads and decompresses the whole chunk containing it. So the chunk shape should match how you read.
| Chunk shape (of a $1024^2$ field) | Fast for | Slow for | Verdict |
|---|---|---|---|
| $(1024, 1024)$ — one chunk = whole field | reading a whole snapshot | reading a small region (still fetches all 8 MB) | fine if you only read wholes |
| $(1, 1024)$ — one row per chunk | reading a row | reading a column or a square region | poor for 2D regions |
| $(128, 128)$ — square tiles | whole-field and square regions of interest | nothing badly | the balanced choice |
| $(16, 16)$ — tiny tiles | pinpoint element access | everything else (per-chunk overhead dominates) | too small |
The group's common case is whole-snapshot reads and its occasional case is a square region across time. Square tiles of $(128, 128)$ serve both: a whole read touches all $8 \times 8 = 64$ chunks in order (essentially as fast as one big chunk), and a $128 \times 128$ region-of-interest touches just one chunk instead of dragging in the entire field. Tiny chunks lose to per-chunk bookkeeping; whole-field chunks make the region case read 64× more data than it needs. We choose $(128, 128)$, and — the point — we can say exactly why.
Design rule. Make the chunk the shape and size of your typical read: big enough that per-chunk overhead is negligible (tens of kB to a few MB), small enough that a partial read does not drag in data you will not use. A $128 \times 128$ double chunk is $128^2 \times 8 = 131{,}072$ bytes — a comfortable 128 kB.
Phase 4 — Optimize the Compression Level
Deflate takes a level from 1 (fast, less shrinkage) to 9 (slow, most shrinkage). The right level depends on whether writing or reading dominates, and smooth scientific fields hit diminishing returns early:
| Deflate level | Relative write cost | Typical ratio on a smooth field | Use when |
|---|---|---|---|
| 0 (off) | 1× | 1.0× | you are compute-bound and disk is free |
| 1 | ~1.2× | ~2.5× | writing dominates; you want some shrinkage cheaply |
| 4–6 | ~2× | ~3× | the sweet spot for most runs |
| 9 | ~5×+ | ~3.2× | archival, write-once read-many, CPU to spare |
The jump from level 1 to 6 buys real ratio; the jump from 6 to 9 buys little ratio for a lot of CPU on smooth data. With shuffle already improving the ratio, level 5 is the deliberate choice: near the best ratio at a modest write cost. (Ratios are Tier-2 illustrative — smooth fields compress well, noisy ones far less; always measure on your data before committing an archive.)
⚡ Optimization, honestly. Compression trades CPU for disk and I/O bandwidth. On a machine where the disk is the bottleneck (common in HPC), compressing can make writing faster overall — fewer bytes to push — even though it costs CPU. On a machine where the CPU is saturated with physics, a high level steals cycles from the simulation. Choose the level for your bottleneck; there is no universal best.
Phase 5 — Estimate and Validate the Payoff
Quantify what the design achieves. A short program computes the storage before and after (pure arithmetic, exact output):
program archive_payoff
use, intrinsic :: iso_fortran_env, only: dp => real64, int64
implicit none
integer(int64), parameter :: nx = 1024, ny = 1024, n_snaps = 1000
real(dp), parameter :: ratio = 3.0_dp
integer(int64) :: raw_bytes
real(dp) :: raw_gb, comp_gb
raw_bytes = nx * ny * 8_int64 * n_snaps
raw_gb = real(raw_bytes, dp) / 1.0e9_dp
comp_gb = raw_gb / ratio
print '(a, i0)', 'bytes/snapshot : ', nx * ny * 8_int64
print '(a, f0.1)', 'raw archive (GB) : ', raw_gb
print '(a, f0.1)', 'gzip5 archive (GB) : ', comp_gb
end program archive_payoff
bytes/snapshot : 8388608
raw archive (GB) : 8.4
gzip5 archive (GB) : 2.8
Hand-check: $1024^2 \times 8 = 8{,}388{,}608$ bytes per snapshot; $\times 1000 = 8.388\ldots \times 10^9$ bytes $= 8.4$ GB raw; at 3× that is $2.8$ GB on disk. The chunking-and-compression design turns an 8.4 GB archive into a 2.8 GB one that also supports fast region-of-interest reads — the storage win and the access win in a single decision.
Finally, a reader that lists the archive's contents validates the structure (requires HDF5; not run). Using
h5gn_members_f to count the datasets under /fields:
integer :: nmembers, hdferr
call h5gn_members_f(file_id, '/fields', nmembers, hdferr)
print '(a, i0)', 'snapshots in archive: ', nmembers
snapshots in archive: 1000
Sanity check. A correct build reports 1,000 members under
/fields, each an $8 \times 8$-chunk, shuffle+deflate-5 dataset of shape $(1024, 1024)$ thath5dump -Hconfirms — and a total file well under the 3 GB the estimate predicts. If the count is short, a run aborted; if a dataset is uncompressed, the property list was not passed toh5dcreate_f.
Discussion Questions
- The group reads whole snapshots often and square regions occasionally. Justify $(128, 128)$ chunks over $(1024, 1024)$ using both access patterns. What would you choose if they only ever read wholes?
- Why does
h5pset_shuffle_fimprove the ratio onreal(dp)fields specifically, and why must it come before deflate in the filter pipeline? - On an I/O-bound cluster, compression can make writing faster despite costing CPU. Explain the mechanism, and name the machine where the opposite is true.
- We chose one-dataset-per-snapshot over a single 3D dataset with an extendible time axis. Give one advantage of each design, and the API cost that pushed us toward the simpler one.
Your Turn: Extensions
- Option A (build). Implement the
/metagroup: create it withh5gcreate_f, then attachnx,ny,dx,dy, andalphaas attributes (h5acreate_f/h5awrite_f). Why store run parameters once in/metarather than as attributes on every snapshot dataset? - Option B (optimize). Add a
chunkandlevelsweep: write the same field with chunk shapes $\{(1024,1024), (128,128), (16,16)\}$ and levels $\{0, 5, 9\}$, and predict (from the tables) which combination minimizes file size and which minimizes write time. Which single combination would you ship? - Option C (design, ⭐⭐⭐). Sketch the 3D-extendible-dataset alternative: a single
temperature(x, y, time)withtimeunlimited, written a snapshot at a time via a hyperslab. List the extra calls it needs versus Phase 2, and state one workload where it clearly beats one-dataset-per-step.
Key Takeaways
- The access pattern is the design input. Chunk shape decides which reads are cheap. Match the chunk to the typical read — square tiles for mixed whole/region access — and say why. Guessing the pattern is how archives end up slow.
- Compression is a tunable trade, not a switch. Level 5 with shuffle is the smooth-field sweet spot; level 9 buys little extra ratio for a lot of CPU, and level 0 is right only when disk is free. Choose for your bottleneck, and measure on your data — the ratios here are illustrative.
- Shuffle before deflate. Byte-reordering aligns the near-identical high bytes of smooth floating-point fields, and it must precede the compressor in the pipeline to help it.
- Hierarchy makes a big archive navigable. A
/metagroup and zero-padded/fields/step_NNNNNNdatasets let both a human and a program find their way through a thousand snapshots — the "filesystem in a file" earning its keep. - Estimate the payoff in numbers. 8.4 GB → 2.8 GB and fast region reads, from two deliberate parameters. Design decisions you can defend with arithmetic are the ones that survive review.