Skip to content

Commit 8dc167a

Browse files
Vectorized EDF/BDF window decode fast path
Uniform-sampling-rate EDF/BDF windows decode through a strided gather plus an optional-numba kernel writing calibrated samples directly into the caller's output buffer, replacing the per-channel Python loop. Big-endian chunks are byteswapped to native order first; uniform stim channels apply the legacy truncating bitmask on the fast path; projector/compensation reads keep the exact legacy route. EDF/BDF/GDF file handles persist per process (PID-keyed LRU).
1 parent 856cb23 commit 8dc167a

5 files changed

Lines changed: 393 additions & 8 deletions

File tree

mne/io/edf/_bdf_numba.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Numba-accelerated BDF (24-bit little-endian) sample decoding.
2+
3+
Optional acceleration: falls back to the vectorized-numpy path in
4+
``mne.io.edf.edf._read_ch`` when numba is unavailable.
5+
"""
6+
7+
# Authors: The MNE-Python contributors.
8+
# License: BSD-3-Clause
9+
# Copyright the MNE-Python contributors
10+
11+
import numpy as np
12+
13+
from ..._numba import jit
14+
15+
16+
@jit()
17+
def decode_int24(buf): # pragma: no cover
18+
"""Decode packed 24-bit little-endian samples to int32.
19+
20+
``buf`` is a ``(n_samples, 3)`` uint8 array whose rows hold the low,
21+
middle, and high bytes of each signed sample.
22+
"""
23+
n = buf.shape[0]
24+
out = np.empty(n, dtype=np.int32)
25+
for i in range(n):
26+
# plain-Python integer arithmetic so the non-numba fallback follows
27+
# the same semantics as the jitted version (values stay within
28+
# [-2**23, 2**23) after the sign fix, so int32 stores never overflow)
29+
v = int(buf[i, 0]) | (int(buf[i, 1]) << 8) | (int(buf[i, 2]) << 16)
30+
if v >= (1 << 23):
31+
v -= 1 << 24
32+
out[i] = v
33+
return out

mne/io/edf/_edf_numba.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Numba-accelerated EDF/BDF digital-to-physical window decoding.
2+
3+
Optional acceleration: falls back to the vectorized-numpy path in
4+
``mne.io.edf.edf._read_segment_file`` when numba is unavailable.
5+
"""
6+
7+
# Authors: The MNE-Python contributors.
8+
# License: BSD-3-Clause
9+
# Copyright the MNE-Python contributors
10+
11+
from ..._numba import jit
12+
13+
14+
@jit(fastmath=False)
15+
def decode_window(digital, cal_v, off_v, gain_v, out): # pragma: no cover
16+
"""Decode a block of digital samples to physical units.
17+
18+
``digital`` is a ``(k, n_blocks, buf_len)`` (possibly strided) integer
19+
view of raw digital samples; ``cal_v``, ``off_v``, and ``gain_v`` are
20+
length-``k`` float64 vectors; ``out`` is a ``(k, n_blocks * buf_len)``
21+
float64 array whose rows are filled with blocks concatenated along the
22+
sample axis such that::
23+
24+
out[i, b * buf_len + j] = ((digital[i, b, j] * cal[i]) + off[i]) * gain[i]
25+
26+
replicating exactly the operation order of the vectorized-numpy fallback
27+
(hence ``fastmath=False``: no FMA contraction or reassociation is
28+
allowed, so results are bit-identical to separate multiply/add rounding).
29+
"""
30+
k = digital.shape[0]
31+
n_blk = digital.shape[1]
32+
n_smp = digital.shape[2]
33+
for i in range(k):
34+
cal = cal_v[i]
35+
off = off_v[i]
36+
gain = gain_v[i]
37+
out_i = out[i]
38+
for b in range(n_blk):
39+
base = b * n_smp
40+
for j in range(n_smp):
41+
out_i[base + j] = ((digital[i, b, j] * cal) + off) * gain
42+
43+
44+
@jit(fastmath=False)
45+
def decode_window_into(
46+
dst, digital, cal_v, off_v, gain_v, s0, w
47+
): # pragma: no cover
48+
"""Decode into a possibly-strided 2-D destination.
49+
50+
``dst`` is ``(k, w)`` with arbitrary strides (e.g., a column slice of the
51+
caller's output buffer); ``digital`` is the ``(k, n_blocks, buf_len)``
52+
strided integer view covering whole data records; ``s0`` is the first
53+
sample to take from that view and ``w`` the number of samples to write,
54+
so edge records at window boundaries are handled without temporaries.
55+
Elementwise op order is identical to :func:`decode_window`.
56+
"""
57+
k = digital.shape[0]
58+
n_smp = digital.shape[2]
59+
for i in range(k):
60+
cal = cal_v[i]
61+
off = off_v[i]
62+
gain = gain_v[i]
63+
dst_i = dst[i]
64+
dig_i = digital[i]
65+
for t in range(w):
66+
g = s0 + t
67+
b = g // n_smp
68+
j = g - b * n_smp
69+
dst_i[t] = ((dig_i[b, j] * cal) + off) * gain

mne/io/edf/_open.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,64 @@
11
# Authors: The MNE-Python contributors.
22
# License: BSD-3-Clause
3-
# Copyright the MNE-Python contributors.
3+
# Copyright the MNE-Python contributors
44

5+
import os
56
from pathlib import Path
67

78
from ..._fiff.open import _NoCloseRead
89
from ...utils import _file_like, _validate_type, logger
910

11+
# Persistent read handles for EDF/BDF/GDF files. Readers seek before every
12+
# read, so a shared handle is safe; keying by PID keeps forked worker
13+
# processes (e.g., PyTorch DataLoader workers) from sharing file-offset state
14+
# through an inherited descriptor.
15+
_HANDLE_CACHE = {}
16+
_MAX_HANDLES = 8
17+
18+
19+
class _NoCloseCached(_NoCloseRead):
20+
"""A file object whose context manager detaches instead of closing.
21+
22+
Used for handles shared through the per-process LRU cache: leaving the
23+
reader's ``with`` block must not close a descriptor other reads may still
24+
use.
25+
"""
26+
27+
def close(self): # noqa: D102
28+
pass
29+
30+
def __exit__(self, *args): # noqa: D105
31+
# detach rather than close; the cache owns the lifetime
32+
return False
33+
34+
35+
def _get_cached_fid(fname):
36+
"""Return a persistent binary handle for *fname* (per process)."""
37+
key = (os.getpid(), str(fname))
38+
hit = _HANDLE_CACHE.get(key)
39+
if hit is not None:
40+
hit.seek(0) # match fresh-open semantics
41+
return hit
42+
fid = open(fname, "rb")
43+
cached = _NoCloseCached(fid)
44+
_HANDLE_CACHE[key] = cached
45+
while len(_HANDLE_CACHE) > _MAX_HANDLES:
46+
old_key = next(iter(_HANDLE_CACHE))
47+
try:
48+
_HANDLE_CACHE.pop(old_key).fid.close()
49+
except Exception:
50+
pass
51+
return cached
52+
1053

1154
def _gdf_edf_get_fid(fname, **kwargs):
1255
"""Open a EDF/BDF/GDF file with no additional parsing."""
1356
if _file_like(fname):
1457
logger.debug("Using file-like I/O")
1558
fid = _NoCloseRead(fname)
1659
fid.seek(0)
17-
else:
18-
_validate_type(fname, [Path, str], "fname", extra="or file-like")
19-
logger.debug("Using normal I/O")
20-
fid = open(fname, "rb", **kwargs) # Open in binary mode
21-
return fid
60+
return fid
61+
_validate_type(fname, [Path, str], "fname", extra="or file-like")
62+
logger.debug("Using normal I/O")
63+
kwargs.pop("buffering", None) # cached handle manages its own buffering
64+
return _get_cached_fid(Path(fname))

0 commit comments

Comments
 (0)