Normalize byte order before calibrating strided integer buffers - #14252
Open
bruAristimunha wants to merge 2 commits into
Open
Normalize byte order before calibrating strided integer buffers#14252bruAristimunha wants to merge 2 commits into
bruAristimunha wants to merge 2 commits into
Conversation
FIFF stores data sample-major, so _read_segment_file hands _mult_cal_one the
transpose of a big-endian tag: consecutive samples of one channel sit a whole
row apart. np.multiply has no fast loop for input that is byte-swapped *and*
strided, so it swaps element by element while it also casts to float64 and
transposes.
Swapping up front in one contiguous pass is cheaper. On a 133 MB int16 file
raw.get_data() goes 109.3 -> 91.9 ms (1.19x); the 13 MB shipped test_raw.fif
goes 9.27 -> 7.72 ms (1.20x).
Instrumenting the read loop shows why this is where the time is: _mult_cal_one
is 86-93% of it, while the tag reads are 7-13% and seek/reshape are noise.
The guard is narrow because the win is. Measured across every dtype and layout
the readers actually produce:
- big-endian int, strided (FIFF, EGI simple-binary): 1.28-1.33x
- big-endian int, contiguous (CTF): ~1.00x, so skipped
- big-endian float (Artemis123, float32 FIFF): 0.85-1.15x, unreliable,
so skipped -- an early version without the float exclusion regressed
Artemis123 to 0.90x
- 8-byte input: 0.52-0.76x, skipped
- native byte order: untouched
Output is bit-identical: full get_data() compared with np.array_equal across
FIFF (int16, float32), CTF, EGI, Artemis123, BrainVision and KIT.
bruAristimunha
requested review from
agramfort,
drammock and
larsoner
as code owners
August 29, 2026 19:58
bruAristimunha
added a commit
to bruAristimunha/mne-python
that referenced
this pull request
Aug 29, 2026
Measuring the kernel's activation cost properly changes the picture in this
PR, so the gate it asked for is now implemented -- and the headline numbers in
the PR description need correcting.
Resolving the kernel costs ~159 ms in a fresh process and ~90 ms when numba is
already imported. That does NOT amortize across processes: with numba's on-disk
cache hit (verified: 1 cache hit, 0 misses) the artifact still has to be loaded,
and two consecutive processes both paid ~90 ms. The earlier '~0.09 s' figure was
measured with numba pre-imported and understated the cold case.
Against a measured saving of ~0.25 ms per MB of integer payload and ~0.145 ms
per MB of float payload, break-even is ~640 MB (int) / ~1.1 GB (float) cold, and
~360 MB / ~620 MB warm. _read_segment_file is the only place that knows the size
of a whole request, so the decision is made there, once per read, and the
resolved kernel (or None) is passed to _mult_cal_one. Requests below the
threshold never import numba at all -- verified.
What a user actually sees, fresh process, first get_data():
payload mne-tools#14252 only + gated kernel
13 MB 11.2 ms 10.5 ms 1.06x (kernel off)
130 MB 110.1 ms 109.2 ms 1.01x (kernel off)
433 MB 305.4 ms 302.4 ms 1.01x (kernel off)
920 MB 625.7 ms 556.2 ms 1.12x (kernel on)
The kernel only pays off when its cost is spread over several reads in one
process -- a pipeline or a notebook rather than a one-shot script:
920 MB payload, one process, cumulative
after 1 read 671 -> 604 ms 1.11x
after 3 reads 2022 -> 1480 ms 1.37x
after 5 reads 3335 -> 2345 ms 1.42x
So the 1.84x in the PR description is a warm-process number and overstates the
one-shot case; it is corrected there.
Also sets fastmath=False on the kernel. This decodes stored values rather than
approximating a computation, so reassociation buys nothing and only risks
changing them; output stays bit-identical with the kernel forced on.
910 tests pass with the gate, 236 with MNE_USE_NUMBA=false.
Contributor
Author
|
Now, I am done with the things that I investigate during the week @larsoner, no more PRs. The speed up should be massive! |
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.
What does this implement/fix?
Instrumenting the FIFF read loop on a 133 MB file shows where the time actually
goes:
fid.seekfrombuffer)reshape_mult_cal_oneIt is not I/O. FIFF stores data sample-major, so
_read_segment_filehands_mult_cal_onethe transpose of a big-endian tag:onehas shape(376, 600)with strides(2, 752), so consecutive samples of one channel are752 bytes apart — a cache line per element. A single
np.multiplyis then askedto byte-swap, cast to float64, transpose and scale all at once, and NumPy has no
fast ufunc loop for input that is byte-swapped and strided, so it swaps
element by element.
Swapping up front in one contiguous pass is cheaper.
Numbers
Median of 6 interleaved process pairs against a pristine
mainworktree:test_raw.fifWhy the guard is narrow
The win is narrow, so the guard is too. Measured across every dtype and layout
the readers actually produce:
An earlier version without the float exclusion regressed Artemis123 to 0.90x,
because it reads in 16 MiB blocks and the
astypetemporary blows cache. Thefloat and contiguity conditions exist to avoid exactly that.
For calibration: native
>i2transposed costs 169 µs against 247 µsbyte-swapped, and this change reaches 186 µs — it recovers essentially the whole
byte-swap penalty. What remains is an inherent cache-hostile transpose.
Correctness
main: 37/37 readable fixtures across 18 formatscompared with
np.array_equal.pytest mne/io mne/_fiff mne/tests/test_epochs.py mne/tests/test_evoked.py:1196 passed.
Additional information
AI disclosure: I directed the work and reviewed and tested every change; Claude
Code (Claude Opus 5) instrumented the read loop, ran the dtype/layout matrix and
the A/B measurements, and made the edits under my direction.