Skip to content

Commit 3e783e6

Browse files
Speed up EDF and BDF reading (#14237)
Co-authored-by: Eric Larson <larson.eric.d@gmail.com>
1 parent bb034ad commit 3e783e6

8 files changed

Lines changed: 181 additions & 21 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Speed up preloading Raw data and reading uniform EDF and BDF data by avoiding unnecessary setup and using pure-NumPy record-stride decoders, by `Bruno Aristimunha`_.

mne/io/base.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -650,9 +650,10 @@ def _preload_data(self, preload):
650650
data_buffer = preload
651651
if isinstance(preload, bool | np.bool_) and not preload:
652652
data_buffer = None
653-
t = self.times
653+
n_times = self.n_times
654+
last_time = (n_times - 1) / self.info["sfreq"]
654655
logger.info(
655-
f"Reading 0 ... {len(t) - 1} = {0.0:9.3f} ... {t[-1]:9.3f} secs..."
656+
f"Reading 0 ... {n_times - 1} = {0.0:9.3f} ... {last_time:9.3f} secs..."
656657
)
657658
self._data = self._read_segment(data_buffer=data_buffer)
658659
assert len(self._data) == self.info["nchan"]

mne/io/edf/edf.py

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -595,11 +595,37 @@ def _read_ch(fid, subtype, samp, dtype_byte, dtype=None):
595595
assert dtype is not None
596596
# BDF
597597
if subtype == "bdf":
598-
ch_data = read_from_file_or_buffer(fid, dtype=dtype, count=samp * dtype_byte)
599-
ch_data = ch_data.reshape(-1, 3).astype(INT32)
600-
ch_data = (ch_data[:, 0]) + (ch_data[:, 1] << 8) + (ch_data[:, 2] << 16)
601-
# 24th bit determines the sign
602-
ch_data[ch_data >= (1 << 23)] -= 1 << 24
598+
assert dtype_byte == 3
599+
expected = samp * dtype_byte
600+
try:
601+
raw = read_from_file_or_buffer(fid, dtype=dtype, count=expected)
602+
except ValueError as err:
603+
raise RuntimeError(
604+
f"Could not read {expected} requested BDF bytes"
605+
) from err
606+
if raw.size != expected:
607+
raise RuntimeError(
608+
f"Only {raw.size} of {expected} requested BDF bytes could be read"
609+
)
610+
# Read each 3-byte sample as the low bytes of an overlapping 4-byte
611+
# word, mask off the byte borrowed from the next sample, then move the
612+
# sign bit to bit 31 and shift back down to sign-extend it. The last
613+
# sample has no next sample to borrow from, so it is done by hand.
614+
# This is equivalent to, and ~3x faster than, the readable version:
615+
#
616+
# ch_data = raw.reshape(-1, 3).astype(INT32)
617+
# ch_data = ch_data[:, 0] | (ch_data[:, 1] << 8) | (ch_data[:, 2] << 16)
618+
# ch_data <<= 8 # sign-extend bit 23
619+
# ch_data >>= 8
620+
ch_data = np.empty(samp, dtype=INT32)
621+
packed = np.ndarray(
622+
(max(samp - 1, 0),), dtype="<u4", buffer=raw, strides=(dtype_byte,)
623+
)
624+
np.bitwise_and(packed, (1 << 24) - 1, out=ch_data[:-1])
625+
if samp:
626+
ch_data[-1] = int(raw[-3]) | int(raw[-2]) << 8 | int(raw[-1]) << 16
627+
ch_data <<= 8
628+
ch_data >>= 8
603629

604630
# GDF data and EDF data
605631
else:
@@ -608,10 +634,11 @@ def _read_ch(fid, subtype, samp, dtype_byte, dtype=None):
608634
return ch_data
609635

610636

637+
_EDF_CHUNK_BYTES = 10 * 1024 * 1024 # read roughly this much per chunk
638+
639+
611640
def _read_segment_file(data, idx, fi, start, stop, raw_extras, filenames, cals, mult):
612641
"""Read a chunk of raw data."""
613-
from scipy.interpolate import interp1d
614-
615642
n_samps = raw_extras["n_samps"]
616643
buf_len = int(raw_extras["max_samp"])
617644
dtype = raw_extras["dtype_np"]
@@ -634,11 +661,20 @@ def _read_segment_file(data, idx, fi, start, stop, raw_extras, filenames, cals,
634661

635662
# We could read this one EDF block at a time, which would be this:
636663
ch_offsets = np.cumsum(np.concatenate([[0], n_samps]), dtype=np.int64)
637-
block_start_idx, r_lims, _ = _blk_read_lims(start, stop, buf_len)
664+
block_start_idx, r_lims, d_lims = _blk_read_lims(start, stop, buf_len)
638665
# But to speed it up, we really need to read multiple blocks at once,
639666
# Otherwise we can end up with e.g. 18,181 chunks for a 20 MB file!
640-
# Let's do ~10 MB chunks:
641-
n_per = max(10 * 1024 * 1024 // (ch_offsets[-1] * dtype_byte), 1)
667+
n_per = max(_EDF_CHUNK_BYTES // (ch_offsets[-1] * dtype_byte), 1)
668+
669+
# When every picked channel stores buf_len samples per data record there is
670+
# nothing to resample, so the picks form a plain (n_picks, n_times) block we
671+
# can calibrate in one go straight into `data`. Mixed sampling rates, a
672+
# projector, or a stim channel that needs interpolating use the per-channel
673+
# loop below instead.
674+
n_picks = len(idx_arr)
675+
picks = read_sel[:n_picks] # picked signal channels, in output row order
676+
uniform = mult is None and bool((n_samps[picks] == buf_len).all())
677+
stim_rows = [j for j, i in enumerate(idx_arr) if i in stim_channel_idxs]
642678

643679
with _gdf_edf_get_fid(filenames, buffering=0) as fid:
644680
# Extract data
@@ -647,7 +683,9 @@ def _read_segment_file(data, idx, fi, start, stop, raw_extras, filenames, cals,
647683
# first read everything into the `ones` array. For channels with
648684
# lower sampling frequency, there will be zeros left at the end of the
649685
# row. Ignore TAL/annotations channel and only store `orig_sel`
650-
ones = np.zeros((len(orig_sel), data.shape[-1]), dtype=data.dtype)
686+
# `ones` has no rows on the fast path, which writes into `data` itself
687+
n_stage = 0 if uniform else len(orig_sel)
688+
ones = np.zeros((n_stage, data.shape[-1]), dtype=data.dtype)
651689
# save how many samples have already been read per channel
652690
n_smp_read = [0 for _ in range(len(orig_sel))]
653691

@@ -663,6 +701,23 @@ def _read_segment_file(data, idx, fi, start, stop, raw_extras, filenames, cals,
663701
r_sidx = r_lims[ai][0]
664702
r_eidx = buf_len * (n_read - 1) + r_lims[ai + n_read - 1][1]
665703

704+
if uniform:
705+
block = np.empty((n_picks, n_read, buf_len), many_chunk.dtype)
706+
for j, ci in enumerate(picks):
707+
block[j] = many_chunk[:, ch_offsets[ci] : ch_offsets[ci + 1]]
708+
for ci in read_sel[n_picks:]: # annotation channels
709+
tal_data.append(
710+
many_chunk[:, ch_offsets[ci] : ch_offsets[ci + 1]].copy()
711+
)
712+
out = data[:, d_lims[ai][0] : d_lims[ai + n_read - 1][1]]
713+
flat = block.reshape(n_picks, n_read * buf_len)[:, r_sidx:r_eidx]
714+
np.multiply(flat, cal[idx_arr, np.newaxis], out=out)
715+
out += offsets[idx_arr, np.newaxis]
716+
out *= gains[idx_arr, np.newaxis]
717+
for j in stim_rows:
718+
out[j] = np.bitwise_and(out[j].astype(int), 2**17 - 1)
719+
continue
720+
666721
# loop over selected channels, ci=channel selection
667722
for ii, ci in enumerate(read_sel):
668723
# This now has size (n_chunks_read, n_samp[ci])
@@ -682,6 +737,8 @@ def _read_segment_file(data, idx, fi, start, stop, raw_extras, filenames, cals,
682737

683738
if n_samps[ci] != buf_len:
684739
if orig_idx in stim_channel_idxs:
740+
from scipy.interpolate import interp1d
741+
685742
# Stim channel will be interpolated
686743
old = np.linspace(0, 1, n_samps[ci] + 1, True)
687744
new = np.linspace(0, 1, buf_len, False)
@@ -735,6 +792,11 @@ def _read_segment_file(data, idx, fi, start, stop, raw_extras, filenames, cals,
735792

736793
_mult_cal_one(data[:, :], ones, idx, cals, mult)
737794

795+
if uniform:
796+
# stands in for the `data_view *= cals` that _mult_cal_one applies; the
797+
# block above is skipped because the fast path leaves n_smp_read zero
798+
data *= cals
799+
738800
if len(tal_data) > 1:
739801
tal_data = np.concatenate([tal.ravel() for tal in tal_data])
740802
tal_data = tal_data[np.newaxis, :]

mne/io/edf/tests/test_edf.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import datetime
66
import gc
7+
import sys
78
from contextlib import nullcontext
89
from functools import partial
910
from io import BytesIO
@@ -67,6 +68,52 @@
6768
misc = ["EXG1", "EXG5", "EXG8", "M1", "M2"]
6869

6970

71+
def _multi_record(source, destination, n_records=6):
72+
"""Give a one-record EDF/BDF file several records, so windows cross them."""
73+
blob = bytearray(source.read_bytes())
74+
if int(blob[236:244]) != 1:
75+
return source # already spans several records
76+
header_nbytes = int(blob[184:192])
77+
blob[236:244] = f"{n_records:<8}".encode("ascii")
78+
destination.write_bytes(blob[:header_nbytes] + blob[header_nbytes:] * n_records)
79+
return destination
80+
81+
82+
@pytest.mark.parametrize("chunk_bytes", (10 * 1024 * 1024, 4096)) # one chunk, several
83+
@pytest.mark.parametrize(
84+
"reader, path",
85+
(
86+
# EDF+: an annotation channel at its own rate, plus two stim channels
87+
(read_raw_edf, data_dir / "test_stim_channel.edf"),
88+
(read_raw_edf, edf_stim_channel_path),
89+
(read_raw_bdf, bdf_path), # BDF: 24-bit samples and a Status channel
90+
),
91+
ids=("edf+", "edf", "bdf"),
92+
)
93+
def test_uniform_decode_matches_loop(reader, path, chunk_bytes, monkeypatch, tmp_path):
94+
"""Test uniform-rate records decode exactly like the per-channel loop."""
95+
path = _multi_record(path, tmp_path / f"uniform{path.suffix}")
96+
monkeypatch.setattr(edf.edf, "_EDF_CHUNK_BYTES", chunk_bytes)
97+
raw = reader(path, preload=False, verbose="error")
98+
buf_len = int(raw._raw_extras[0]["max_samp"])
99+
n_channels = len(raw.ch_names)
100+
picks = np.array([n_channels - 1, 1, 0]) # reordered, and includes any stim channel
101+
kwargs = dict(picks=picks, start=buf_len // 2, stop=4 * buf_len + 13)
102+
# the fast path writes into `data` itself, the per-channel loop stages the
103+
# result and calls _mult_cal_one -- so counting it tells the two apart
104+
staged = []
105+
stage = edf.edf._mult_cal_one
106+
monkeypatch.setattr(
107+
edf.edf, "_mult_cal_one", lambda *a: (staged.append(1), stage(*a))[1]
108+
)
109+
want = raw.get_data(**kwargs)
110+
assert not staged
111+
# an identity projector sends the same values down the per-channel loop
112+
raw._projector = np.eye(n_channels)
113+
assert_array_equal(want, raw.get_data(**kwargs))
114+
assert staged
115+
116+
70117
def test_orig_units():
71118
"""Test exposure of original channel units."""
72119
raw = read_raw_edf(edf_path, preload=True)
@@ -319,8 +366,10 @@ def test_edf_data_broken(tmp_path):
319366

320367

321368
@pytest.mark.parametrize("method", ("constructor", "load_data"))
322-
def test_edf_preload_memmap_ownership(method, tmp_path):
369+
def test_edf_preload_memmap_ownership(method, tmp_path, monkeypatch):
323370
"""Test ownership of a populated EDF preload memmap."""
371+
# uniformly sampled EDF must not need (and hence not import) interpolation
372+
monkeypatch.setitem(sys.modules, "scipy.interpolate", None)
324373
memmap_fname = tmp_path / f"edf-{method}-memmap.dat"
325374
if method == "constructor":
326375
raw = read_raw_edf(edf_stim_channel_path, preload=memmap_fname)
@@ -349,6 +398,37 @@ def test_duplicate_channel_labels_edf():
349398
assert raw.ch_names == EXPECTED_CHANNEL_NAMES
350399

351400

401+
@pytest.mark.parametrize(
402+
"values",
403+
(
404+
np.array([], dtype=np.int32),
405+
np.array([-(1 << 23), -1, 0, 1, (1 << 23) - 1], dtype=np.int32),
406+
np.random.default_rng(42).integers(
407+
-(1 << 23), 1 << 23, size=1000, dtype=np.int32
408+
),
409+
),
410+
)
411+
def test_read_ch_bdf_int24(values):
412+
"""Test exact signed 24-bit BDF decoding, including the last sample."""
413+
unsigned = values.astype(np.int64) & ((1 << 24) - 1)
414+
packed = np.column_stack((unsigned, unsigned >> 8, unsigned >> 16)).astype(np.uint8)
415+
with BytesIO(packed.tobytes()) as fid:
416+
got = _read_ch(
417+
fid, subtype="bdf", samp=len(values), dtype_byte=3, dtype=np.uint8
418+
)
419+
assert_array_equal(got, values)
420+
421+
422+
def test_read_ch_bdf_short_read(tmp_path):
423+
"""Test truncated signed 24-bit BDF data raises instead of mixing bytes."""
424+
fname = tmp_path / "truncated.bdf"
425+
fname.write_bytes(b"\x00" * 5)
426+
# np.frombuffer raises on a short buffer, np.fromfile just returns less
427+
for fid in (BytesIO(b"\x00" * 5), fname.open("rb")):
428+
with fid, pytest.raises(RuntimeError, match="requested BDF bytes"):
429+
_read_ch(fid, subtype="bdf", samp=2, dtype_byte=3, dtype=np.uint8)
430+
431+
352432
def test_parse_annotation(tmp_path):
353433
"""Test parsing the tal channel."""
354434
# test the parser

mne/io/eeglab/eeglab.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from ..._fiff.meas_info import create_info
1717
from ..._fiff.pick import _PICK_TYPES_KEYS
1818
from ..._fiff.utils import _find_channels, _mult_cal_one, _read_segments_file
19-
from ...annotations import Annotations, read_annotations
19+
from ...annotations import Annotations
2020
from ...channels import make_dig_montage
2121
from ...defaults import DEFAULTS
2222
from ...epochs import BaseEpochs
@@ -501,7 +501,7 @@ def __init__(
501501
)
502502

503503
# create event_ch from annotations
504-
annot = read_annotations(input_fname, uint16_codec=uint16_codec)
504+
annot = _read_annotations_eeglab(eeg)
505505
self.set_annotations(annot)
506506
_check_boundary(annot, None)
507507

mne/io/eeglab/tests/test_eeglab.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
import shutil
77
from copy import deepcopy
8+
from unittest.mock import Mock
89

910
import numpy as np
1011
import pytest
@@ -497,7 +498,7 @@ def test_eeglab_annotations(fname):
497498

498499

499500
@testing.requires_testing_data
500-
def test_eeglab_read_annotations():
501+
def test_eeglab_read_annotations(monkeypatch):
501502
"""Test annotations onsets are timestamps (+ validate some)."""
502503
annotations = read_annotations(raw_fname_mat)
503504
validation_samples = [0, 1, 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31]
@@ -524,9 +525,12 @@ def test_eeglab_read_annotations():
524525
)
525526

526527
# test if event durations are imported correctly
528+
check_load_mat = Mock(wraps=mne.io.eeglab.eeglab._check_load_mat)
529+
monkeypatch.setattr(mne.io.eeglab.eeglab, "_check_load_mat", check_load_mat)
527530
raw = read_raw_eeglab(raw_fname_event_duration, preload=True, montage_units="dm")
528531
# file contains 3 annotations with 0.5 s (64 samples) duration each
529532
assert_allclose(raw.annotations.duration, np.ones(3) * 0.5)
533+
assert check_load_mat.call_count == 1
530534

531535

532536
@testing.requires_testing_data

mne/io/tests/test_raw.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,14 @@
5050
)
5151

5252

53-
def _fail_if_times_materialized(*args, **kwargs):
54-
pytest.fail("The full Raw.times vector was materialized")
53+
@pytest.fixture
54+
def fail_if_times_materialized(monkeypatch):
55+
"""Fail the test if the full Raw.times vector is ever constructed."""
56+
57+
def _fail(*args, **kwargs):
58+
pytest.fail("The full Raw.times vector was materialized")
59+
60+
monkeypatch.setattr("mne.io.base._arange_div", _fail)
5561

5662

5763
def assert_named_constants(info):
@@ -105,11 +111,16 @@ def test_orig_units():
105111
BaseRaw(info, last_samps=[1], orig_units=True)
106112

107113

108-
def test_set_annotations_does_not_materialize_times(monkeypatch):
114+
def test_preload_does_not_materialize_times(fail_if_times_materialized):
115+
"""Test preloading does not construct the full time vector."""
116+
raw = read_raw_fif(raw_fname, preload=True, verbose="error")
117+
assert raw.preload
118+
119+
120+
def test_set_annotations_does_not_materialize_times(fail_if_times_materialized):
109121
"""Test annotation bounds use the scalar recording endpoint."""
110122
raw = read_raw_fif(raw_fname, preload=False, verbose="error")
111123
annotations = Annotations([0.0], [0.1], ["test"])
112-
monkeypatch.setattr("mne.io.base._arange_div", _fail_if_times_materialized)
113124
raw.set_annotations(annotations)
114125
assert len(raw.annotations) == 1
115126

tools/vulture_allowlist.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
# License: BSD-3-Clause
1010
# Copyright the MNE-Python contributors.
1111

12+
fail_if_times_materialized
1213
numba_conditional
1314
options_3d
1415
invisible_fig

0 commit comments

Comments
 (0)