Skip to content

perf(io): open the HDF5 file once per DataStub read - #910

Merged
ehennestad merged 2 commits into
mainfrom
hdf5-lazy-array-open-once-per-read
Oct 6, 2026
Merged

ehennestad merged 2 commits into
mainfrom
hdf5-lazy-array-open-once-per-read

Conversation

@ehennestad

@ehennestad ehennestad commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

Speeds up small DataStub reads by opening the HDF5 file once per read instead of twice: 0.89 ms instead of 1.29 ms per read in the example.

Problem — Code that reads a dataset from file in many small pieces pays a fixed cost on every read, and part of that cost is opening the file twice. In the example below, reading an 8x3 block from an 8x15000 dataset 1000 times takes 1.29 ms per read. Each read opens the file and the dataset to look up the dataspace, closes them, and then opens both again to read the data. Nothing fails, so the only symptom is that small reads are slower than they need to be. This showed up while profiling ragged-column reads for #909.

Solution — Each read now opens the file and the dataset once and uses them both to look up the dataspace and to read. The same reads take 0.89 ms each.

Additional change: read a contiguous range of a one-dimensional dataset as one block

Problem — Reading a range of a one-dimensional dataset through a DataStub, such as stub(1:211722), builds one HDF5 point per element, and the selection is slow to build and walk. In the second example below, reading all 211,722 elements of a 1-D dataset takes 28.5 ms per read. The cause is that load_mat_style turns every single-subscript selection into a point selection, also when the selection is one contiguous range.

Solution — A selection that is one contiguous range of a one-dimensional dataset is now read as one block (a hyperslab). The same read takes 5.6 ms. A selection with gaps is still read as points, which is the faster choice there.

Why in this PR — Both changes reduce the cost of a load_mat_style call and edit the same function.

What changed

  • A DataStub read that selects data by index, such as stub(:, 1:3) or stub.load(1:10), opens the file once instead of twice. The data it returns is unchanged.
  • Reading one contiguous range of a one-dimensional dataset, such as stub(1:1000) or the index reads of a ragged column in getRow, selects one block instead of one point per element. The data it returns is unchanged.
Implementation notes
  • HDF5LazyArray.load_mat_style gets the dataspace with H5D.get_space on the dataset it reads from, instead of calling the private getSpace method, which opens and closes the file itself. refreshSizeInfo still uses getSpace.
  • An empty selection returns before the file is opened, because it reads through a nested load_mat_style(1) call.
  • On the machine used for this PR, one open and close of the file and dataset costs about 0.24 ms. Most of the remaining time in a small read is MATLAB-side work: building the selection (io.space.segmentSelection, io.space.findShapes), type handling in hdf2mat, and cleanup of H5ML.id handles. perf(io): read several DataStub selections in one pass #912 avoids that work for getRow by reading several selections in one pass.
  • A single subscript that is one contiguous run of a one-dimensional dataset is selected with H5S.select_hyperslab; any other single subscript is selected with H5S.select_elements as before. On a 211,722-element dataset, selecting all elements took 22 ms as points and 4 ms as one hyperslab. For 2,500 short runs with gaps, points took 1 ms and a union of 2,500 hyperslabs 74 ms, so selections with gaps stay as points.
  • H5S.select_hyperslab takes only double-precision arguments, and a selection can arrive as another integer class: getRow on main passes uint64 indices. The start is therefore converted to double.

Examples

Many small reads from one dataset

The snippet writes an 8x15000 dataset, then reads a random 8x3 block from it 1000 times through a DataStub and reports the time per read.

fileName = 'small_reads.h5';
if isfile(fileName); delete(fileName); end
h5create(fileName, '/data', [8 15000]);
h5write(fileName, '/data', rand(8, 15000));
stub = types.untyped.DataStub(fileName, '/data');

rng(2)
numCalls = 1000;
firstColumns = randi(15000 - 3, 1, numCalls);
block = stub(:, 1:3); % the first read also loads the size and type
tic
for k = 1:numCalls
    block = stub(:, firstColumns(k):(firstColumns(k) + 2));
end
fprintf('reads: %d of an 8x3 block\n', numCalls);
fprintf('time per read: %.2f ms\n', 1000 * toc / numCalls);

Before — Each read opens the file twice and takes 1.29 ms.

reads: 1000 of an 8x3 block
time per read: 1.29 ms

After — Each read opens the file once and takes 0.89 ms.

reads: 1000 of an 8x3 block
time per read: 0.89 ms

Timings are from one machine and vary with platform and disk.

One long read of a one-dimensional dataset

The snippet writes a 1-D dataset of 211,722 values, then reads all of it 20 times through a DataStub and reports the time per read.

fileName = 'long_vector.h5';
if isfile(fileName); delete(fileName); end
h5create(fileName, '/data', 211722);
h5write(fileName, '/data', rand(211722, 1));
stub = types.untyped.DataStub(fileName, '/data');

block = stub(1:3); % the first read also loads the size and type
numCalls = 20;
tic
for k = 1:numCalls
    block = stub(1:211722);
end
fprintf('elements per read: %d\n', numel(block));
fprintf('time per read: %.1f ms\n', 1000 * toc / numCalls);

Before — The range is read as 211,722 separate points, and each read takes 28.5 ms.

elements per read: 211722
time per read: 28.5 ms

After — The range is read as one block, and each read takes 5.6 ms.

elements per read: 211722
time per read: 5.6 ms

How to test

Run the snippets above on main and on this branch and compare the time per read lines. tests.unit.io.backend.HDF5LazyArrayTest/loadMatStyleReadsOneDimensionalSelections checks one-dimensional reads with and without gaps, including a uint64 selection, against the same indexing in memory. The other read paths are covered by the existing tests, which pass unchanged:

results = runtests({'tests.unit.dataStubTest', 'tests.unit.io'}, 'IncludeSubpackages', true);
disp(table(results))

Related pull requests

Checklist

  • Have you ensured the PR description clearly describes the problem and solutions?
  • Have you checked to ensure that there aren't other open or previously closed Pull Requests for the same change?
  • If this PR fixes an issue, is the first line of the PR description fix #XX where XX is the issue number?

🤖 Generated with Claude Code

@ehennestad
ehennestad marked this pull request as ready for review September 30, 2026 19:36
@codecov

codecov Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.30%. Comparing base (621b043) to head (d3c3a53).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #910   +/-   ##
=======================================
  Coverage   95.30%   95.30%           
=======================================
  Files         239      239           
  Lines        8798     8802    +4     
=======================================
+ Hits         8385     8389    +4     
  Misses        413      413           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

ehennestad added a commit that referenced this pull request Oct 2, 2026
The contiguous-range hyperslab in load_mat_style is a reader change with
its own cause, so it moves to #910, which already reduces the per-read
cost of load_mat_style. This reverts commit 11e2c85.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ehennestad and others added 2 commits October 6, 2026 07:36
HDF5LazyArray.load_mat_style opened the file and dataset to get the
dataspace, closed them, and opened both again to read. One open and
close of the file and dataset costs about 0.24 ms, against about 1.2 ms
for a whole small read.

The dataspace now comes from the dataset that is opened for the read.
The empty-selection path returns before anything is opened, because it
reads through a nested load_mat_style call.

Reading an 8x3 block from an 8x15000 dataset goes from 1.29 ms to
0.89 ms per call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
load_mat_style turned every single-subscript selection into a point
selection with one point per element. Selecting all 211,722 elements of
a 1-D dataset took 22 ms as points and 4 ms as one hyperslab.

A selection that is one contiguous run of a one-dimensional dataset is
now read as a hyperslab. Any other selection keeps the point selection,
which is the faster one when there are gaps: for 2,500 short runs a
point selection took 1 ms and a union of 2,500 hyperslabs 74 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ehennestad
ehennestad added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit bb882cc Oct 6, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants