Skip to main content

CA3 §6.5 under DLIO

The measurement methodology.md asked for: CA3's derived form against HDF5, under a benchmark this repository did not design.

Two adversarial reviews of the in-house harness found the same defect both times — every bias ran toward CA3, because one author chose the format, the workload, the access pattern, the metric, and both baselines' configurations. The instruction was to stop self-designing and run under DLIO, which MLCommons audits as MLPerf Storage. DLIO picks the workload, the sampler, the batch size, the thread count and the metric; it ships its own HDF5 reader, so the baseline is Argonne's. CA3 supplies only get_sample, through the reader_classname plugin seam in measurement/dlio/ca3_reader.py.

A third reader was added to make the comparison interpretable. measurement/dlio/raw_reader.py reads flat files with no container at all — no header, no framing, no index. Whatever it reaches is what Python file I/O costs when the format costs nothing, and both real formats are then read as a distance from that floor. Without it, "our format is faster" and "we wrote a tighter loop" are the same measurement.

Two regimes, and they answer different questions

The same three readers give opposite-looking answers depending on what the bottleneck is, and the pair is the result rather than either half.

recordsdatasetbounded by
A. reader-path8 MiB1.07 GB, page-cache residentCPU in the read path
B. bandwidth144 MiB99 GiB against 59.4 GiB of RAMthe SSD

Regime A — what the container costs in CPU

128 files of 8 MiB, batch 8, 4 read threads, file and sample shuffle on, 20 epochs, 12 independent runs per format.

samples/s (mean ± sd, n=12)range% of the zero-format ceiling
raw, no container564.06 ± 9.01547.8 – 576.3100 %
CA3 §6.5558.48 ± 10.71542.5 – 578.299.0 %
HDF5, DLIO's own reader538.99 ± 9.11525.0 – 558.095.6 %

Welch's t-test on the three pairs:

comparisontdfverdict
CA3 vs HDF54.8021.4significant (+3.6 %)
raw vs HDF56.7822.0significant
raw vs CA31.3821.4not established

The third row carries the argument. CA3 is statistically indistinguishable from reading files that have no format at all, which bounds how much of its result can be plugin skill: nothing can beat "no container", and CA3 already matches it. HDF5 is significantly below both.

Mechanistically, §6.5 costs one multiplication — sample i begins at blocks_at + block_bytes × i, so the reader seeks once and reads one block with no parse between the file and the array. h5py resolves a dataset path, consults a chunk cache, and copies through the HDF5 library. All three move identical bytes, enforced per-sample by measurement/dlio/verify_identical.py.

Conditionally, this holds where the read path is the constraint: records small enough, or storage fast enough, that CPU decides. Regime B is what happens when that stops being true.

Regime B — what the container costs when the device saturates

704 files of 144 MiB (99 GiB, against 59.4 GiB of RAM), batch 2, 4 read threads, 3 epochs, 3 runs per format. Formats run one at a time and are converted in place, because three copies at this size do not fit on the disk.

rep1rep2rep3mean ± sdMB/s
HDF515.2 (cold)19.319.317.92 ± 2.382580
CA3 §6.519.119.019.219.12 ± 0.072753
raw, no container19.219.519.919.52 ± 0.322811

All three land within 2.1 % of each other, and so does a container that does not exist. At 144 MiB per record, CA3's framing is 98 bytes out of 150,994,944 — 0.00007 % — and the pipeline is saturating the SSD at ~2.8 GB/s. Nothing a format does can register against that.

So CA3's advantage is real and bounded: it lives in regime A. Once records are large enough that the device is the constraint, no container helps, and claiming a win here would be claiming credit for the SSD.

The ordering inside regime B is cache state, not format

Do not read the 2.1 % spread as a ranking. Three things confound it, all recorded rather than corrected:

  • HDF5's rep1 (15.2) is the only genuinely cold pass in the table. Every format is measured immediately after being written — HDF5 by the generator, CA3 and raw by the conversion that produced them — so each starts partly resident. HDF5 alone had 35 minutes between its write and its first read.
  • Cache warming saturates, and the saturated value is reproducible. HDF5 reps 2 and 3 agree to three significant figures (19.3, 19.3), and CA3 never varies at all (19.1, 19.0, 19.2). The equilibrium is roughly 60 GB of the 99 GiB resident.
  • raw was still warming when the sequence ended (19.2 → 19.5 → 19.9), which is most of why it tops the table.

This is not a cold-storage measurement and must not be quoted as one. At 99 GiB against 59.4 GiB the majority of reads reach the SSD, but a large minority do not. The epoch timings show it directly: 60.7 s cold, then 47 s once warm, a 23 % speedup that is page cache and nothing else. A genuinely cold measurement needs a dataset well past 128 GiB, or the ability to drop the cache between epochs — and Windows offers no supported way to do the latter.

What neither regime establishes

Single-sample-per-file only. num_samples_per_file: 1 is forced by a DLIO defect (below), so §6.5's addressing within a large chunk — the property the record was designed for — is exercised by the chunk-table walk but never by a long block region. That is the measurement most worth adding next.

Whole samples only. Nothing here reads part of a sample, so hyperslabs, projections and compressed chunks are all untested.

AU is 1–12 %, and that is the intended condition rather than a failure. computation_time is 1 ms so the simulated accelerator demands data faster than any storage supplies. At unet3d's own 0.323 s every format reaches AU 100 % and the comparison cannot separate them at all; train_au_meet_expectation: fail here means the run is storage-bound, which is the point. measurement/dlio/sweep_au.py turns this into the question MLPerf Storage exists to answer — the fastest accelerator each format holds above 90 % — and has not yet been run at either size.

The defect that mattered most was ours

The HDF5 and CA3 runs were reading different bytes, and an earlier version of this document stated they read the same ones.

ca3_hdf5_stress.yaml carried generate_data: True, the run was launched with ++workflow.generate_data=False to suppress it, and DLIO ignores ++ overrides — so it regenerated every HDF5 file with fresh random content while the CA3 copies still held the previous generation's. Both were 8,386,816 bytes per sample, both were valid files, both benchmarked without complaint. 128 of 128 files mismatched and only a hash could see it.

The timing was very likely unaffected — incompressible random bytes of equal length either way, no compression — but the claim was an unchecked property written down as an established one. Every number in this document was re-measured after the fix; the regime A table is 12 fresh runs per format, not the earlier three.

measurement/dlio/verify_identical.py now hashes every sample in every format and exits non-zero on any mismatch. convert_in_place.py performs the same check before it releases a source file, because --delete-source removes the only thing an output could be compared against. Run the verifier before measuring anything.

Four defects found in DLIO, and what each cost

Each is a claim to re-derive rather than trust; the file and line are given so a reader can check whether a later version still has it.

1. record_length_bytes, not record_length (utils/config.py:903). The key was renamed and only the new spelling is parsed, so a config using the old one silently takes the 64 KiB default. This produced an 18 MB dataset where 1 GiB was requested, and the first runs measured DLIO's startup rather than storage. DLIO's own shipped workload configs still use the old spelling.

2. record_length is then clobbered to 1 (utils/config.py:553).

# hdf5 specific derivations
self.record_length = np.prod(self.record_dims) * self.record_element_bytes

The comment says HDF5 but no format guard exists, and record_dims defaults to []. np.prod([]) is 1.0, so record_length becomes 1 byte for any config that does not set record_dimsafter record_length_bytes was read correctly. Because statscounter.py:208 derives I/O throughput as samples/s × record_size rather than measuring it, the reported "Training I/O Throughput (MB/second)" was wrong by a factor of 8.4 million in one run and right in the next. Setting record_dims explicitly fixes it, and the configs here do: 19.2 samples/s × 144 MiB = 2768 MB/s, which is what DLIO now reports.

3. ++ command-line overrides do not reach the config. DLIO reads the YAML dict rather than Hydra's resolved config, so ++train.computation_time= and ++workflow.generate_data= are accepted and ignored — a run reports set value: {'mean': 0.323} regardless. Every variant therefore needs its own file, which is why the *_stress and *_huge configs exist rather than flags. This defect is also what silently regenerated the dataset above.

4. main.py:230 divides a file count by a sample count.

samples_per_step = num_samples_per_file * batch_size * comm_size
aligned = (num_files // samples_per_step) * samples_per_step

With more than one sample per file this demands more files than any real workload has, trims the file list to zero, and the run reads nothing. DLIO's own resnet50_h100 config trims to zero under it. This is what forces num_samples_per_file: 1.

Two results that reversed, and why they are recorded

A 3-epoch run said the opposite of a 20-epoch run. It reported HDF5 at 912 samples/s against CA3 at 514 — HDF5 1.77× ahead. It was noise, and the standard deviation said so: 376 on a mean of 912, or 41 %. At 128 samples per epoch and roughly 900 samples/s the measurement window is 0.14 s, which on Windows with spawn workers is dominated by process startup rather than reading. Widening to 20 epochs dropped HDF5's standard deviation to 37.5 and reversed the ordering.

A 3-run comparison claimed a separation that 12 runs did not support. On the verified data, three runs per format gave CA3 +3.1 % over HDF5 with overlapping ranges and t = 1.4 — not established. Twelve runs gave +3.6 % at t = 4.8. The conclusion survived; the evidence for it at n=3 did not exist, and the earlier "3 of 3, no overlap" phrasing was an artifact of a small sample.

Both are recorded because each was one report away from being published, and nothing about either looked wrong except a number nobody had to read.

Reproducing

pip install h5py torch
pip install "dlio_benchmark @ git+https://github.com/argonne-lcf/dlio_benchmark@main"

The PyPI 2.0.0 release imports dftracer.logger, which no published pydftracer provides; install from git.

Regime A, from a directory where DLIO has generated its HDF5 dataset:

export PYTHONPATH=measurement/dlio
python measurement/dlio/generate_ca3.py <data-dir>

Then convert the flat control, check the three agree, and run:

python measurement/dlio/generate_raw.py <data-dir>
python measurement/dlio/verify_identical.py <data-dir>

Regime B, which is destructive by necessity and needs ~110 GB free:

python measurement/dlio/run_out_of_cache.py <data-dir> 3

generate_ca3.py and convert_in_place.py copy DLIO's own samples rather than generating any, so every run reads the same bytes. The CA3 files are named .npz and the flat files .npy because FormatType is a closed enum with no ca3 member; the declared format decides the filename extension and reader_classname decides what parses it (utils/config.py:294).

A note for anyone extending this: run DLIO with its output redirected to a file, never through subprocess.run(capture_output=True). The spawn workers inherit that pipe and communicate() waits for an EOF that cannot arrive while a stuck grandchild holds the write end, so a run that died on its first batch presented instead as a process sitting at 0.0 MB/s of I/O and 3.3 seconds of CPU across seventeen minutes, with the exception never reaching anyone.