Skip to content

Speed up EDF and BDF reading - #14237

Merged
larsoner merged 14 commits into
mne-tools:mainfrom
bruAristimunha:perf/raw-io-cold-path
Aug 29, 2026
Merged

Speed up EDF and BDF reading#14237
larsoner merged 14 commits into
mne-tools:mainfrom
bruAristimunha:perf/raw-io-cold-path

Conversation

@bruAristimunha

@bruAristimunha bruAristimunha commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Reference issue (if any)

None. Supersedes #14215 (and the closed #14214); follow-up to the merged #14212 / #14213.

What does this implement/fix?

Speeds up EDF/BDF reading — first read and full preload — with no new
dependency and no API change.

  • uniform-sampling EDF/BDF records decode as one block instead of a Python loop
    over channels, including EDF+ files whose annotation channel is stored at its
    own rate
  • BDF 24-bit samples unpack via an overlapping <u4 view instead of a
    three-term shift/add with a boolean sign fixup
  • Raw setup no longer materializes the full times vector just to compute
    scalar bounds
  • EEGLAB reuses the metadata it already parsed instead of re-parsing the .set
    for annotations

Output is bit-identical to main in all cases: 77/77 array snapshots and 56/56
annotation snapshots over 28 EDF/BDF/GDF files, compared with np.array_equal.

64 channels at 256 Hz for 10 minutes, warm page cache, medians of 5 interleaved
A/B rounds against a pristine main worktree on the same machine. "EDF+"/"BDF+"
are the same files with an EDF Annotations channel at its own rate:

case main this PR speedup
BDF+ preload 202.1 ms 31.1 ms 6.5x
BDF preload 106.9 ms 22.6 ms 4.7x
BDF 200 x 4 s windows 231.8 ms 60.0 ms 3.9x
BDF+ 200 x 4 s windows 245.1 ms 70.8 ms 3.5x
EDF+ 200 x 4 s windows 86.9 ms 49.4 ms 1.8x
EDF 200 x 4 s windows 84.5 ms 49.2 ms 1.7x
EDF+ preload 23.2 ms 16.1 ms 1.4x
EDF preload 17.3 ms 13.1 ms 1.3x
mixed-rate EDF (falls back) 20.9 ms 19.8 ms 1.1x

Why EDF+ matters here

An EDF+ annotation channel is normally stored at a different rate from the
signal channels, so a data record is not an (n_channels, max_samp) matrix and
cannot simply be reshaped — a 64-channel 256 Hz record with a 60-sample TAL
channel holds 16444 values where the reshape needs 16640. Gating on all
channels sharing a rate would exclude those files, which is most clinical EDF.
Gating on the selected channels and gathering each picked channel by its own
offset covers them: fast-path coverage over the EDF/BDF test corpus is 18/22
files, the remaining four being genuinely mixed-rate reads that must resample.

Shape of the change

The decode is one branch inside the chunk loop _read_segment_file already
had — gather the picked channels by their record offsets, calibrate the block
straight into data, mask any stim rows — so there is no second copy of the
read loop to keep in sync, no helper functions, and no precomputed layout
state. mne/io/edf/edf.py is +64/-11.

The tests use hooks the reader already provides rather than recorder classes:
an identity projector routes the same values through the per-channel loop, and
counting _mult_cal_one (which only the loop calls) tells the two paths apart.
Mutation testing catches 15/15 injected defects — wrong pick order, dropped
calibration terms, off-by-one record trimming, a skipped stim mask, a gate that
ignores picks, and reassociated arithmetic that would break bit-exactness.

This PR is EDF/BDF only. An earlier revision of it also touched
_read_segments_file, BrainVision and EEGLAB at once, which was too much to
review in one go; the rest was split out and measured separately:

  • BrainVision block sizing is now Read BrainVision data in cache-sized blocks #14241 (+25/-2). It stands alone and does
    not depend on this PR.
  • _read_segments_file mmap + a threaded read are not proposed anywhere yet.
    No MNE fixture is large enough to reach the 64 MB threading threshold (the
    largest .eeg is 3.7 MB), so they were untested at any realistic scale.
  • Threaded EDF reads: worth 13-22% on preload, but
    mne.parallel.parallel_func(prefer="threads") already exists for exactly this,
    so it should reuse that and ship its own benchmark rather than add a second
    ThreadPoolExecutor to the codebase.
  • A per-read memory budget: dropped because it never fired. The ~10 MB chunk
    sizing already bounds temporaries under 50 MB of its 64 MB cap, even for an
    adversarial 2-channel/6-hour file with reversed picks.

Why this replaces #14215

#14215 added an optional engine="edfio" to read_raw_edf to get EDF preloads
from ~453 ms down to ~368 ms, at the cost of an optional dependency, a new public
parameter, and a restricted feature set (uniform rates only, all channels EEG, no
meas_date). The native decoder here is faster than that on the same fixture
while keeping every EDF feature, so the engine switch is no longer worth its
maintenance surface. Closing #14215 in favour of this.

Additional information

AI disclosure: I directed the work and reviewed and tested everything; Claude Code
(Claude Opus 5) wrote most of the code edits and the benchmark harness under my
direction.

The stride decoder reshaped a whole data record into an (n_channels, max_samp)
matrix, which requires every channel to share a sampling rate. An EDF+
annotation channel usually does not: for a 64-channel 256 Hz file with a
60-sample TAL channel a record holds 16444 values while the reshape needs
16640, so 'stride_layout' was None and every EDF+ read fell back to the
per-channel loop.

Gate on the *selected* channels instead, and when a record is not rectangular
gather each picked channel by its own offset. Files whose records are
rectangular keep the existing reshape unchanged.

Fast-path coverage across the EDF/BDF corpus goes from 6/22 files to 18/22.
EDF+ windowed reads 1.76x, EDF+ preload 1.28x, BDF+ windowed reads 1.42x;
files without an annotation channel are unaffected. Output stays bit-identical
to main: 73/73 array snapshots and 52/52 annotation snapshots across 26 files.
# Conflicts:
#	mne/io/base.py
#	mne/io/tests/test_raw.py
Four review passes (reuse, simplification, efficiency, altitude) against the
shape of mne-tools#14216. Measured rather than assumed:

- byte-budget heuristic: never fires. n_per already caps a chunk near 10 MiB
  of source bytes, so temporaries stay under 50 MB of the 64 MB cap even for
  an adversarial 2-channel/6-hour file with reversed picks.
- n_read == 1 branch: 0/200 hits on the windowed benchmark, and the general
  branch produces the same values.
- threading: real (13-22% on preload) but reachable only via direct_output,
  and it is one of only two ThreadPoolExecutor sites in MNE. mne.parallel
  already has parallel_func(prefer='threads'). Deferred to its own PR with a
  benchmark.
- direct_output: kept, it measures 27-28% on full preload.

_read_segments_file mmap/threading and the BrainVision block sizing are
reverted to main and move to a follow-up: no MNE fixture is large enough to
reach the 64 MB threading threshold (largest .eeg is 3.7 MB), so it was
untested at any realistic scale.

Tests 549 -> 156 lines: four near-identical stride tests merged into one
parametrized test, redundant file_kind axes dropped, and a test asserting
x * 1.0 == x bit-exactly removed.

+884/-29 -> +330/-18. Output stays bit-identical to main: 77/77 array
snapshots and 56/56 annotation snapshots across 28 files.
The decode lived in a 118-line _read_uniform_segment that duplicated the
chunk loop, a _calibrate_uniform_edf helper, and a stride_layout tuple cached
in the header and unpacked far away, gated by a rectangular ->
sel_in_physical_order -> in_physical_order -> direct_output chain. A reviewer
had to hold two copies of the same loop in their head.

It is now one branch inside the loop that was already there: gather the picked
channels by their record offsets, calibrate the block straight into 'data', and
mask any stim rows. No new helper functions, no precomputed tuple, no flag
chain. edf.py goes from 235 to 64 added lines and is measurably faster --
EDF preload 1.26x -> 1.40x and EDF+ preload from parity to 1.47x, because
every case now writes into 'data' directly, not just the aligned one that
direct_output required.

The tests use the two hooks the reader already has instead of recorder
classes: an identity projector routes the same values through the per-channel
loop, and counting _mult_cal_one tells the two paths apart. 308 -> 81 added
lines, and mutation testing catches 15/15 injected defects where the longer
version caught 11.

Output stays bit-identical to main: 77/77 array snapshots and 56/56
annotation snapshots across 28 files.
@bruAristimunha bruAristimunha changed the title Speed up cold-path reading for EDF, BDF, BrainVision and EEGLAB Speed up EDF and BDF reading Aug 28, 2026

@larsoner larsoner left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just two tiny maintainability things, otherwise LGTM!

Comment thread mne/io/edf/edf.py
raise RuntimeError(
f"Only {raw.size} of {expected} requested BDF bytes could be read"
)
ch_data = np.empty(samp, dtype=INT32)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For optimizations like this that are maybe not as readable, I like to leave in a comment the more readable three--liner that would do the same thing. It helps the conceptual model for the next person who has to debug the code (and they can easily switch to using it if they suspect the optimized code is the problem)

Comment thread mne/io/tests/test_raw.py Outdated
Comment on lines +108 to +110
def test_preload_does_not_materialize_times(monkeypatch):
"""Test preloading does not construct the full time vector."""
monkeypatch.setattr("mne.io.base._arange_div", _fail_if_times_materialized)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that there is this monkeypatch a few places, I think maybe we should make a fail_if_times_materialized pytest fixture that does monkeypatch / yield (DRY the code)

Two maintainability points from @larsoner:

- The BDF int24 unpack now carries the equivalent three-liner in a comment, so
  the next person has a conceptual model and something to swap in if they
  suspect the optimized version. Verified equivalent across sizes 1-100000 and
  at the range boundaries; the readable form is ~3x slower.
- _fail_if_times_materialized becomes a fail_if_times_materialized fixture that
  patches and yields, used by both tests. The annotations test now has the
  patch active during the read too, so it is slightly stricter than before.
vulture does not model pytest fixtures: the fixture function looks uncalled and
the two test parameters that request it look like unused variables. Same
treatment as the other fixture names already in the allowlist.
@larsoner
larsoner merged commit 3e783e6 into mne-tools:main Aug 29, 2026
31 checks passed
@larsoner

Copy link
Copy Markdown
Member

Thanks @bruAristimunha !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants