Skip to content

perf(utils): 2-3x faster _StartCountStride (21% on MFDataset reads) - #1524

Merged
jswhit merged 2 commits into
Unidata:masterfrom
dexhunter:perf/faster-start-count-stride
Nov 1, 2026
Merged

jswhit merged 2 commits into
Unidata:masterfrom
dexhunter:perf/faster-start-count-stride

Conversation

@dexhunter

Copy link
Copy Markdown
Contributor

TL;DR

  • This PR makes _StartCountStride in src/netCDF4/utils.py faster. Every Variable and MFDataset read and write calls it.
  • It skips numpy conversion for plain ints and slices, and classifies each index only once.
  • It builds the start, count and stride arrays from Python lists in one step.
  • Measured as wall time of the test_multifile_selected_decoding reads: 165.75 ms -> 131.35 ms, 21% faster.
  • Tests: pytest test/ gives the same result before and after (151 passed, 4 environment failures).

What & Why

Before, the function called np.asarray and np.iterable on each index in three separate passes.
It did this also for plain ints and slices.
It wrote each value into small numpy arrays one element at a time.
It used np.apply_along_axis to expand index arrays into the chunk grid.

Now, plain ints, slices and Ellipsis skip the numpy conversion.
Each index array is checked once, and the result is reused in the later passes.
The per-dimension values go into Python lists, and the function converts them to arrays once.
Index arrays are reshaped and broadcast into the chunk grid instead of apply_along_axis.
The datashape padding work only runs when data is written.

The output stays identical.
The same checks run in the same order, so the error types and messages do not change.
The returned start, count, stride and indices arrays have the same values, dtypes and shapes.

Results

Time per call of _StartCountStride (shape (100, 50, 40)), and one full read for comparison:

Situation Before After Change
int + slices v[3, :, 2:10] 17.95 us 5.31 us 3.4x faster
1-d index array v[[1, 5, 9], :, 0] 24.92 us 10.37 us 2.4x faster
boolean mask v[:, mask, :] 26.38 us 10.15 us 2.6x faster
empty selection v[[], :, 1] 22.35 us 8.17 us 2.7x faster
two index arrays v[[1,4,7], [2,8], :] 31.08 us 15.30 us 2.0x faster
full read of 2000x2000 float64 v[:] 6.82 ms 6.71 ms no change (I/O time dominates)
MFDataset reads from test_multifile_selected_decoding 165.75 ms 131.35 ms 21% faster

The gain matters most for many small reads, for example MFDataset reads.
That case calls the function once for the aggregate and once per file.
For large reads the index work is a very small part of the time, so nothing changes.

Why this is safe and easy to maintain

The change stays inside one private function and keeps its structure and comments.
Behaviour and output do not change, including error messages and the order of the checks.
test_empty_indexing.py, test_utils.py, test_slicing.py and the rest of test/ cover these paths.

Validation

  • Full test suite: cd test && python -m pytest -q .
    • Before and after: 151 passed, 4 skipped, 11255 subtests passed.
    • The same 4 tests fail on both trees because of the local environment (no ncdump binary, no zstd plugin).
  • Outputs were checked for equality with the unmodified function on the test_multifile_selected_decoding reads.
    The check also covered fancy, ellipsis and invalid indices: values, dtypes, shapes, masks and exception messages.
  • Timings: Python 3.12, numpy 2.5.4, one dedicated CPU core, median over repeated runs.
  • Short reproduction:
python -m timeit -s "from netCDF4.utils import _StartCountStride as s" "s((3, slice(None), slice(2, 10)), (100, 50, 40))"

AI assistance

The candidate changes were written by Codex (gpt-6.1-sol) and tracked with Weco Observe.
Each candidate was timed and checked for identical output with an automated harness.
This candidate was picked for readability, and the test suite was run on it.

Weco share link: https://dashboard.weco.ai/share/94aIQh5OIBPswhVZFl0zqgU1yAhTbTiV

Skip numpy conversion for plain ints, slices and Ellipsis, and classify
each index array once instead of in three passes. Build start, count and
stride from Python lists and convert them once, and broadcast index
arrays into the chunk grid instead of using np.apply_along_axis.

Output, error types and messages are unchanged. The MFDataset reads in
test_multifile_selected_decoding go from 165.75 ms to 131.35 ms (21%).

Co-Authored-By: Aiden <aiden@weco.ai>
@CLAassistant

CLAassistant commented Oct 31, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@jswhit

jswhit commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Looks good, thank you.

@jswhit

jswhit commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator

Can you add a Changelog entry please?

@dexhunter

Copy link
Copy Markdown
Contributor Author

Thank you for the review. I added a Changelog entry in 7eeb2c8.

It is under a new version 1.7.6 (not yet released) section, because 1.7.5.1.post1 is already released. The commit changes only the Changelog file.

@jswhit
jswhit merged commit 0a79d16 into Unidata:master Nov 1, 2026
52 of 53 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.

3 participants