Filters, compression & the on-disk layout#
Each column can carry its own HDF5 filter pipeline. h5col models a pipeline as an
ordered list of filters (mirroring the HDF5 filter pipeline), accepting both the
built-ins and hdf5plugin compressors. This notebook compresses a column, checks
the storage savings, and then walks the raw HDF5 structure h5col produced.
import tempfile
from pathlib import Path
import h5py
import hdf5plugin
import numpy as np
from h5col import (
ColumnSpec,
Deflate,
FilterPipeline,
Shuffle,
Table,
)
path = Path(tempfile.gettempdir()) / "h5col_filters.h5"
A per-column filter pipeline#
FilterPipeline([...]) applies filters in order. Here temp_k uses
shuffle + deflate, and pressure uses shuffle + hdf5plugin Zstd compressor. We
generate smooth, compressible data over several thousand rows so the effect is
visible.
n = 20_000
temp_k = 288.0 + 5.0 * np.sin(np.linspace(0, 20, n)).astype("f4")
pressure = (1013.0 + np.cumsum(np.random.default_rng(0).normal(0, 0.1, n))).astype("f4")
f = h5py.File(path, "w")
table = Table.create(
f.create_group("timeseries"),
[
ColumnSpec(name="temp_k", dtype="f4", units="K", chunks=4096,
filters=FilterPipeline([Shuffle(), Deflate(6)])),
ColumnSpec(name="pressure", dtype="f4", units="hPa", chunks=4096,
filters=FilterPipeline([Shuffle(), hdf5plugin.Zstd(clevel=5)])),
],
)
table.append({"temp_k": temp_k, "pressure": pressure})
table.nrows
20000
Storage savings#
Compare each column’s on-disk allocated size to its uncompressed footprint.
_FILTER_NAMES = {1: "deflate", 2: "shuffle", 3: "fletcher32",
32001: "blosc", 32015: "zstd"}
for name in ["temp_k", "pressure"]:
ds = table[name].dataset
dcpl = ds.id.get_create_plist()
raw = ds.dtype.itemsize * table.nrows
stored = ds.id.get_storage_size()
ids = [dcpl.get_filter(i)[0] for i in range(dcpl.get_nfilters())]
filts = [_FILTER_NAMES.get(i, str(i)) for i in ids]
print(f"{name:9s} raw={raw:>8d} stored={stored:>8d} "
f"ratio={raw/stored:4.1f}x filters={filts}")
temp_k raw= 80000 stored= 30647 ratio= 2.6x filters=['shuffle', 'deflate']
pressure raw= 80000 stored= 40781 ratio= 2.0x filters=['shuffle', 'zstd']
Chunk shape and the chunk cache#
Columns are chunked, and h5col picks the default chunk shape for write-once /
read-often (and cloud) access: bigger chunks mean fewer chunks, better
compression, and fewer/larger object-store reads. Rather than hard-code a size,
the default scales with the file’s raw-data chunk cache — which HDF5 ≥ 2.0
defaults to 8 MiB per dataset (it was 1 MiB before). The target is half the
cache, clamped to [2 MiB, 8 MiB], so on HDF5 2.x a column defaults to 4 MiB
chunks. The chunk length therefore scales inversely with the item size — a
1-byte column packs 4× as many rows per chunk as a 4-byte one.
from h5col._hdf5 import target_chunk_bytes
MiB = 1 << 20
rdcc = f.id.get_access_plist().get_cache()[2]
print(f"file chunk cache (rdcc_nbytes): {rdcc // MiB} MiB")
print(f"default chunk target: {target_chunk_bytes(f) // MiB} MiB")
# A table with DEFAULT chunking (no explicit chunks=): every column targets the
# same byte budget, so the row count per chunk tracks the item size.
f.create_group("defaults")
td = Table.create(
f["defaults"],
[
ColumnSpec(name="f8", dtype="f8"), # 8 bytes
ColumnSpec(name="f4", dtype="f4"), # 4 bytes
ColumnSpec(name="i2", dtype="i2"), # 2 bytes
ColumnSpec(name="u1", dtype="u1"), # 1 byte
],
)
for name in ["f8", "f4", "i2", "u1"]:
ds = td[name].dataset
clen = ds.chunks[0]
print(f" {name}: {clen:>9d} rows/chunk = {clen * ds.dtype.itemsize // MiB} MiB")
file chunk cache (rdcc_nbytes): 8 MiB
default chunk target: 4 MiB
f8: 524288 rows/chunk = 4 MiB
f4: 1048576 rows/chunk = 4 MiB
i2: 2097152 rows/chunk = 4 MiB
u1: 4194304 rows/chunk = 4 MiB
Scaling and overrides#
Because the target follows the cache, a file opened with a larger cache gets larger chunks — up to the 8 MiB cap, which keeps chunks friendly to readers that open with the default cache (the chunk shape is baked into the file, but the cache is chosen per open). Two escape hatches bypass the automatic sizing:
Table.create(…, default_chunk_bytes=N)sets the byte target for the table;ColumnSpec(chunks=rows)sets an exact per-column chunk length.
# Reopen with a 64 MiB cache -> the target clamps at the 8 MiB cap, and an
# explicit default_chunk_bytes overrides the cache-derived size entirely.
big_path = Path(tempfile.gettempdir()) / "h5col_bigcache.h5"
with h5py.File(big_path, "w", rdcc_nbytes=64 * MiB) as bf:
print("64 MiB cache -> target", target_chunk_bytes(bf) // MiB, "MiB (capped)")
tb = Table.create(
bf.create_group("t"),
[ColumnSpec(name="x", dtype="f8")],
default_chunk_bytes=1 * MiB, # explicit override wins over the cache
)
print("default_chunk_bytes=1 MiB -> f8 chunk:", tb["x"].dataset.chunks[0], "rows")
64 MiB cache -> target 8 MiB (capped)
default_chunk_bytes=1 MiB -> f8 chunk: 131072 rows
The on-disk layout#
Everything h5col writes is standard HDF5 you can inspect with h5py. Let’s build a
small table that exercises categorical and list columns, then walk the tree —
the table-group attributes (CLASS/VERSION/NROWS), the column datasets, the
CATEGORIES group, and a LIST_COLUMN group’s members.
from h5col import LeafValuesSpec, ListColumnSpec
from h5col._hdf5 import read_str_attr, read_uint64_attr
f.create_group("catalog")
t2 = Table.create(
f["catalog"],
[
ColumnSpec(name="name", dtype=h5py.string_dtype(length=8)),
ColumnSpec(name="band", categories=["red", "green", "blue"], ordered=True),
ListColumnSpec(name="samples", values=LeafValuesSpec(dtype="f4")),
],
)
t2.append({
"name": ["alpha", "beta"],
"band": ["red", "blue"],
"samples": [[1.0, 2.0], [3.0]],
})
def walk(name, obj):
indent = " " * name.count("/")
if isinstance(obj, h5py.Group):
print(f"{indent}{name.split('/')[-1] or '/'}/ (Group)")
else:
print(f"{indent}{name.split('/')[-1]} {obj.shape} {obj.dtype.name[:24]}")
cat = f["catalog"]
print("/catalog CLASS =", read_str_attr(cat, "CLASS"),
"| VERSION =", read_str_attr(cat, "VERSION"),
"| NROWS =", read_uint64_attr(cat, "NROWS"))
print()
cat.visititems(walk)
/catalog CLASS = COLUMN_TABLE | VERSION = 1.0 | NROWS = 2
CATEGORIES/ (Group)
band__CATEGORIES (3,) bytes40
band (2,) int8
name (2,) bytes64
samples/ (Group)
OFFSETS (3,) uint64
VALUES (3,) float32
Notice:
catalog/holds the column datasetsname,band(integer codes), and thesampleslist column group.CATEGORIES/holds thebandlabel dataset, linked from thebandcolumn by an HDF5 object reference.samples/is aLIST_COLUMNgroup withOFFSETS+VALUESmembers.
All of it is ordinary HDF5 — any HDF5 tool can read the bytes; h5col just gives
them the H5Col meaning.
t2.validate()
print("valid ✔")
f.close()
valid ✔