Repository navigation
perf(io): read gapped multi-dimensional selections in one call - #923
Draft
ehennestad wants to merge 1 commit into
Draft
ehennestad wants to merge 1 commit into
ehennestad wants to merge 1 commit into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## fix-get-row-ragged-read-performance #923 +/- ##
=======================================================================
+ Coverage 95.25% 95.28% +0.02%
=======================================================================
Files 239 239
Lines 8880 8915 +35
=======================================================================
+ Hits 8459 8495 +36
+ Misses 421 420 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ehennestad
force-pushed
the
perf-find-shapes-contiguous-runs
branch
from
October 2, 2026 12:34
efb0019 to
9ed6562
Compare
ehennestad
marked this pull request as draft
October 2, 2026 13:22
1 task done
ehennestad
added this pull request to stack #925
October 2, 2026 13:35
2 of 3 tasks
ehennestad
force-pushed
the
perf-find-shapes-contiguous-runs
branch
from
October 6, 2026 05:38
9ed6562 to
9bc4495
Compare
2 of 3 tasks
ehennestad
force-pushed
the
perf-find-shapes-contiguous-runs
branch
from
October 6, 2026 05:47
9bc4495 to
c5329aa
Compare
ehennestad
force-pushed
the
perf-find-shapes-contiguous-runs
branch
from
October 6, 2026 18:09
c5329aa to
7c4f63b
Compare
io.space.findShapes searched for the longest regularly strided block, removed it and repeated, so a selection of R irregular runs cost R passes and the time grew faster than linearly: a single call with 10,000 runs took 113 s. Every multi-subscript DataStub read and every region reference goes through it, so a gapped selection of a multi-dimensional dataset took seconds while the covering span took milliseconds. findShapes now splits the sorted indices into runs of consecutive indices with diff, one Block per run and one Point per isolated index. The strided search runs only when most indices are isolated, and each pass must cover a quarter of the remaining indices, so 1:2:N is still one strided Block while irregular points fall back to the run split after one pass. HDF5 merges each hyperslab into the selection built so far, which costs 1.4 s at 10,000 hyperslabs and 166 s at 100,000, so load_mat_style reads a selection with more than 1,000 hyperslabs in groups of whole runs along the dimension with the most shapes and joins them in memory. The Block constructor uses an arguments block instead of inputParser, which matters when thousands of blocks are built. getRow reads the elements of all requested rows of a file-backed ragged column in one call for every rank; the per-run loop for multi-dimensional columns is no longer needed. Every other row of a 5,000-row table with an 8 x n column takes 0.15 s instead of 2.0 s. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 of 3 tasks
ehennestad
removed this pull request from stack #925
October 6, 2026 20:44
ehennestad
force-pushed
the
perf-find-shapes-contiguous-runs
branch
from
October 6, 2026 20:44
7c4f63b to
a2ca013
Compare
ehennestad
added this pull request to stack #941
October 6, 2026 20:45
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Background — A
DataStubread with two or more subscripts, such asstub(:, columns), goes throughio.space.findShapes, which turns the indices of each dimension into hyperslab shapes, andio.space.getReadSpace, which ORs one hyperslab per shape into the file selection for a singleH5D.read. Region references resolve through the same two functions. The rows of a raggedDynamicTablecolumn are always a union of index ranges: row r spans elementsindex(r-1)+1throughindex(r).Problem — A user selects a subset of a multi-dimensional dataset with gaps between the selected indices, for example the waveforms of every other unit, or an irregular subset of channels or samples of a large
ElectricalSeries. The read takes seconds to minutes while the same read of a contiguous range takes milliseconds. In the first example below, the 11 samples around each of 2,500 spikes, 2,296 runs of a 16-channel LFP series, take 41 s to read in one call, while reading each window in a call of its own takes 2.1 s. The cause isfindShapes: it searches for the longest regularly strided block, removes it and repeats, so a selection of R irregular runs costs R passes and the time grows faster than linearly. A single call with 10,000 runs takes 113 s. #909 works around this ingetRowby reading each run of a multi-dimensional column in a call of its own, which costs one file open per run: the 2,490 correct trials of a 5,000-trial table, 1,213 runs, take 1.25 s to get.Solution —
findShapesnow splits the sorted indices into runs of consecutive indices withdiff, in linear time, and only searches for strided blocks when most indices are isolated, as in1:2:N. The same windows read in 0.28 s, 10,000 runs are split in 0.06 s, andgetRowreads the elements of all requested rows of a column in one call, so the correct trials of a 5,000-trial table in the second example take 0.30 s instead of 1.25 s. HDF5 itself merges each hyperslab into the selection built so far, which costs 1.4 s at 10,000 hyperslabs and 166 s at 100,000, so a selection with more than 1,000 hyperslabs is read in groups along the dimension with the most runs and joined in memory.What changed
io.space.findShapesemits oneBlockwith step 1 per run of consecutive indices and onePointper isolated index, in increasing order. A pure stride such as1:2:Nstill becomes one stridedBlock: when more than half of the indices are isolated, the strided block search runs first, and each pass is accepted only when its block covers a quarter of the remaining indices. That bounds the passes by a logarithm of the number of indices, and irregular isolated indices fall back to the run split after one pass.HDF5LazyArray.load_mat_stylereads a selection with more thanMaxHyperslabsPerRead(1,000) hyperslabs in groups of whole runs along the dimension with the most shapes, each group with its own file selection andH5D.read, and joins the groups along that dimension. Memory use stays that of the selected elements.io.space.shape.Blockconstructor validates with anargumentsblock instead ofinputParser, which brings construction from 45 µs to 12 µs per block. Thousands of blocks are built for a selection with thousands of runs.getRowreads the elements of all requested rows of a file-backed ragged column in one call for every rank. The per-run loop that fix(dynamictable): read ragged columns once per index level in getRow #909 added for multi-dimensional columns is removed.DataStubread now closes the copied file dataspace it reads through. It was left open before.Implementation notes
findShapeskeepsfindOptimalBlockas the strided search. The loop around it runs only while the remaining indices are mostly isolated (numel(runs) > numel(indices)/2) and the best block covers at least a quarter of them (minimumStrideCoverage). The remainder is split into runs byfindRunsandcreateRunShapes.[1:2:99, 4]becomesBlock(1:2:99)andPoint(4); 1,000 random points in 1:50000 become one shape per run with no strided block.readInGroupsinload_mat_stylesplits the sorted unique indices of the split dimension at run boundaries into groups offloor(MaxHyperslabsPerRead / hyperslabs per shape of that dimension)runs. Each group is read and reshaped like a whole selection withreadSelectionandreshapeLoadedData, so the other dimensions come out in the requested order, and the groups are joined withcat. The split dimension is reordered afterwards when its subscript is unsorted or has repeats. The groups never recurse intoreadInGroups.MaxHyperslabsPerReadis 1,000 because the time to OR N hyperslabs is about6 µs × N + 13 ns × N²on this machine: at 1,000 hyperslabs the quadratic part is a third of the linear part. Each group is read from the dataset thatload_mat_stylealready has open, so a group adds oneH5D.readcall and no file or dataset open. Grouping is linear in the number of runs (see Performance).readDataRowsingetRowreads the sorted unique elements of all rows with onereadRowscall and takes each row from that block by position. A column whose rows are all empty is not read;readEmptyRowbuilds the empty rows.load_mat_styleopens the file and dataset once and closes them after its last read. It is split intoreadSelection(read from the open dataset, convert types, close the dataspaces) andreshapeLoadedData(reshape and reorder) so the grouped path reuses them. The reorder step is unchanged, including its handling of':'subscripts.Related pull requests
fix-datastub-unsorted-subscript-order) is merged, and this PR is rebased on it. Its fixes to the reorder step, the':'stand-in loop andisSelectionNormal(i), sit insidereshapeLoadedData, and itsreordered*tests inHDF5LazyArrayTestpass here.hdf5-lazy-array-open-once-per-read) is merged, and this PR is rebased on it. perf(io): open the HDF5 file once per DataStub read #910 opens the file and dataset once at the top ofload_mat_styleand reads a contiguous range of a 1-D dataset as one hyperslab. This PR keeps both:readSelection,readInGroupsandgetReadSpaceuse the dataset thatload_mat_styleopened, so a read in groups opens the file once. The tests of both PRs are inHDF5LazyArrayTest.read-selections-in-one-pass) is stacked on this PR, in stack #925: fix(dynamictable): read ragged columns once per index level in getRow #909 → perf(io): read gapped multi-dimensional selections in one call #923 → perf(io): read several DataStub selections in one pass #912. It addsloadSelectionstoLazyArray,DataStubandDataPipe. ItsgetRowchange, which batched the per-run reads of fix(dynamictable): read ragged columns once per index level in getRow #909 throughloadSelections, is dropped because this PR reads all rows in one call;getRow.mis identical in perf(io): read several DataStub selections in one pass #912 and this PR.Examples
Spike-triggered windows of an LFP series
The snippet writes a 16-channel, 5-minute LFP series at 1 kHz, reads it back and selects the 11 samples around each of 2,500 spikes, once as a single
DataStubread and once with one read per spike.Before — One call takes 41 s, twenty times longer than 2,500 separate reads.
After — One call takes 0.28 s and returns the same data.
The correct trials of a trials table
The snippet writes a 5,000-trial table with a
correctcolumn and a raggedlick_positionscolumn holding the x and y of 1 to 20 licks per trial, reads it back and gets the rows of the correct trials.Before — With #909, the correct trials are read one run at a time.
After — The rows are read in one call per column.
Performance
All timings are medians of three runs after a warm-up call, on one machine: MATLAB 26.1.0.3203278 (R2026a), HDF5 1.14.4, macOS. Before is the #909 branch this PR builds on.
findShapesalone. Selections of R runs with random lengths 1 to 8 and gaps 1 to 20.DataStubreads. The gapped selection has 2,500 runs of 1 to 8 columns with gaps of 1 to 20 between them (11,338 elements, span 5 to 37,189). The span read isstub(:, 5:37189).8 x 50000before8 x 50000after64 x 211722before64 x 211722after1:2:endgetRowend to end. The Units tables are the #787 script from #909 with 20 units (26,583 spikes) and the full size (172 units, 211,722 spikes). The 5,000-row table has an8 x nragged column with 1 to 20 elements per row.toTableafternwbReadgetRow(1:2:20)getRow([1 20])toTableafternwbReadgetRow(1:2:172)getRow([1 172])getRow(1:2:5000)toTableWhere HDF5 becomes the limit.
H5S.select_hyperslabwithH5S_SELECT_ORfor N blocks of 4 columns on a 2-D space, one call per block, single run.Reading in groups of at most 1,000 hyperslabs keeps a one-call gapped read linear in the number of runs (
8 x ndataset, one call, median of three):Without grouping, 50,000 runs would be one selection of 50,000 hyperslabs, which the table above puts between 1.4 s and 166 s to build.
How to test
Run the two snippets above on the base branch and here and compare the
one callandgetRowlines. Then run the tests. TheSpaceTestcases for runs, strides and many runs describe the newfindShapesoutput;testManyRunsSelectExactlyTheInputtakes minutes on the base branch.selectionWithManyRunsIsReadInGroupsinHDF5LazyArrayTestreads 2,001 runs of a 3-D dataset, more than one selection holds, with sorted, reversed and repeated indices.testGetRowWithGapsReadsEachLevelOnceinDynamicTableRaggedReadTestfails on the base branch, where the waveforms of units 1 and 4 are read in two calls.Checklist
🤖 Generated with Claude Code