Skip to content

Normalize byte order before calibrating strided integer buffers - #14252

Open
bruAristimunha wants to merge 2 commits into
mne-tools:mainfrom
bruAristimunha:perf/fiff-byteswap
Open

Normalize byte order before calibrating strided integer buffers#14252
bruAristimunha wants to merge 2 commits into
mne-tools:mainfrom
bruAristimunha:perf/fiff-byteswap

Conversation

@bruAristimunha

Copy link
Copy Markdown
Contributor

What does this implement/fix?

Instrumenting the FIFF read loop on a 133 MB file shows where the time actually
goes:

stage int16 file float32 file
fid.seek 0.1% 0.1%
tag read (I/O + frombuffer) 6.6% 13.2%
reshape 0.1% 0.1%
_mult_cal_one 93.0% 86.4%

It is not I/O. FIFF stores data sample-major, so _read_segment_file hands
_mult_cal_one the transpose of a big-endian tag: one has shape
(376, 600) with strides (2, 752), so consecutive samples of one channel are
752 bytes apart — a cache line per element. A single np.multiply is then asked
to 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 main worktree:

case main this PR
FIFF 133 MB int16 109.3 ms 91.9 ms 1.19x
FIFF 13 MB test_raw.fif 9.27 ms 7.72 ms 1.20x
FIFF 263 MB float32 99.8 ms 101.9 ms 0.98x
CTF / EGI / Artemis123 / BrainVision 0.98–1.01x

Why the guard is narrow

The win is narrow, so the guard is too. Measured across every dtype and layout
the readers actually produce:

input effect
big-endian int, strided (FIFF, EGI simple-binary) 1.28–1.33x
big-endian int, contiguous (CTF) ~1.00x → skipped
big-endian float (Artemis123, float32 FIFF) 0.85–1.15x, unreliable → skipped
8-byte input 0.52–0.76x → skipped
native byte order untouched

An earlier version without the float exclusion regressed Artemis123 to 0.90x,
because it reads in 16 MiB blocks and the astype temporary blows cache. The
float and contiguity conditions exist to avoid exactly that.

For calibration: native >i2 transposed costs 169 µs against 247 µs
byte-swapped, and this change reaches 186 µs — it recovers essentially the whole
byte-swap penalty. What remains is an inherent cache-hostile transpose.

Correctness

  • Bit-identical to main: 37/37 readable fixtures across 18 formats
    compared 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.

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 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.
@bruAristimunha

Copy link
Copy Markdown
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!

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.

1 participant