Skip to content

Commit 76dce45

Browse files
Calibrate raw buffers with an optional numba kernel
_mult_cal_one is 86-93% of the FIFF read loop (tag I/O is 7-13%, seek and reshape are noise). It asks a single np.multiply to byte-swap, cast to float64, transpose and scale at once. NumPy has no fused loop for that, so it falls back to nditer buffering; a jitted loop does the same work in one pass. raw.get_data(), interleaved cross-process medians: main byteswap only + kernel FIFF 133 MB int16 109.6 ms 91.4 (1.20x) 59.5 (1.84x) FIFF 263 MB float32 99.5 ms 101.2 (0.98x) 65.2 (1.53x) FIFF test_raw.fif 9.1 ms 7.7 (1.18x) 4.9 (1.87x) CTF 1.34 ms 1.35 (0.99x) 1.03 (1.31x) The kernel also rescues float32 FIFF, which the byte-order commit alone cannot help: swapping floats in NumPy costs about what it saves, but numba refuses byte-swapped input outright, so the swap becomes worthwhile once the kernel follows it. Follows the existing numba pattern: the jitted function lives in its own lazily-imported module, is gated on has_numba, and falls back to the NumPy path (kept intact, including its integer byte-order fix) when numba is missing or MNE_USE_NUMBA=false. TRADE-OFF for review: this puts numba on the raw-reading path, so the first get_data() in a process pays ~0.09 s, which is almost entirely 'import numba' -- the very cost mne/_numba.py's docstring says it keeps out of mne.fixes. Break-even is roughly 200 MB of FIFF per session; below that it is a small regression. MNE_USE_NUMBA=false opts out. Gating the kernel on total read size would avoid the cost for small reads but needs a size hint threaded through _read_segment_file, which touches every reader's signature. Verified on both paths: 910 tests pass with and without numba, and get_data() is bit-identical to main for 37/37 readable fixtures across 18 formats.
1 parent 40149fe commit 76dce45

3 files changed

Lines changed: 60 additions & 12 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Speed up raw data reading by calibrating each buffer with an optional numba kernel, by `Bruno Aristimunha`_.

mne/_fiff/_utils_numba.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Jitted helpers for :mod:`mne._fiff.utils`.
2+
3+
Kept in its own module so that importing mne does not import numba.
4+
"""
5+
6+
# Authors: The MNE-Python contributors.
7+
# License: BSD-3-Clause
8+
# Copyright the MNE-Python contributors.
9+
10+
from .._numba import jit
11+
12+
13+
@jit()
14+
def _scale_into(one, cals, data_view):
15+
"""Cast, transpose and calibrate one raw buffer in a single pass."""
16+
n_channels, n_times = one.shape
17+
for ci in range(n_channels):
18+
cal = cals[ci]
19+
for ti in range(n_times):
20+
data_view[ci, ti] = one[ci, ti] * cal

mne/_fiff/utils.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,24 @@ def _find_channels(ch_names, ch_type="EOG"):
7171
return eog_idx
7272

7373

74+
_SCALE_KERNEL = None
75+
76+
77+
def _get_scale_kernel():
78+
"""Return the jitted calibration kernel, or None if numba is unavailable."""
79+
global _SCALE_KERNEL
80+
if _SCALE_KERNEL is None:
81+
from .._numba import has_numba
82+
83+
if not has_numba:
84+
_SCALE_KERNEL = False
85+
else:
86+
from ._utils_numba import _scale_into
87+
88+
_SCALE_KERNEL = _scale_into
89+
return _SCALE_KERNEL or None
90+
91+
7492
def _mult_cal_one(data_view, one, idx, cals, mult):
7593
"""Take a chunk of raw data, multiply by mult or cals, and store."""
7694
assert data_view.shape[1] == one.shape[1], (
@@ -89,18 +107,27 @@ def _mult_cal_one(data_view, one, idx, cals, mult):
89107
# Benchmark (128 ch x 1024 samples): ~85 -> ~30 us per call
90108
# on BrainVision/FIF window reads.
91109
one = one[idx]
92-
swapped_ints = not one.dtype.isnative and one.dtype.kind in "iu"
93-
if swapped_ints and not one.flags.c_contiguous:
94-
# FIFF stores sample-major, so `one` is the transpose of a
95-
# big-endian integer tag and consecutive samples of a channel
96-
# sit a row apart. np.multiply has no fast loop for input that
97-
# is byte-swapped *and* strided, so it swaps element by element
98-
# while it also casts and transposes; swapping up front in one
99-
# pass is cheaper. Floats are excluded because there the swap
100-
# costs about what it saves, and contiguous input already has a
101-
# fast loop.
102-
one = one.astype(one.dtype.newbyteorder("="))
103-
np.multiply(one, cals.reshape(-1, 1), out=data_view, casting="unsafe")
110+
kernel = _get_scale_kernel()
111+
if kernel is not None:
112+
# numba refuses byte-swapped input outright, so normalize
113+
# first; the kernel more than pays for the extra pass
114+
if not one.dtype.isnative:
115+
one = one.astype(one.dtype.newbyteorder("="))
116+
kernel(one, cals.reshape(-1), data_view)
117+
else:
118+
# NumPy fallback. FIFF hands us the transpose of a big-endian
119+
# tag, and np.multiply has no fast loop for input that is
120+
# byte-swapped *and* strided, so it swaps element by element
121+
# while it also casts and transposes. Swapping up front in one
122+
# pass is cheaper, but only for integers -- for floats it costs
123+
# about what it saves.
124+
if (
125+
not one.dtype.isnative
126+
and one.dtype.kind in "iu"
127+
and not one.flags.c_contiguous
128+
):
129+
one = one.astype(one.dtype.newbyteorder("="))
130+
np.multiply(one, cals.reshape(-1, 1), out=data_view, casting="unsafe")
104131
else:
105132
one = np.asarray(one, dtype=data_view.dtype)
106133
np.take(one, idx, axis=0, out=data_view)

0 commit comments

Comments
 (0)