Skip to content

Commit 856cb23

Browse files
Read FIF simple numeric tags through a memory map
FIF raw segments are read as byte-offset views into a PID-keyed memory map of the file instead of open/seek/read per call; buffer entries are selected with searchsorted on the sorted bounds. gzip, file-like objects, and non-simple tag types keep the legacy path. The generic memory-map cache in _read_segments_file also serves the other binary readers.
1 parent 8321243 commit 856cb23

3 files changed

Lines changed: 167 additions & 11 deletions

File tree

mne/_fiff/_mmap_cache.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""PID-keyed memmap caching for direct byte-offset reads.
2+
3+
Used by readers that need random access into raw data files (currently the
4+
FIF raw reader). Keyed by PID so forked worker processes (e.g., PyTorch
5+
DataLoader workers) create their own mapping instead of sharing a parent's,
6+
and validated against file size/mtime so stale mappings are never reused.
7+
"""
8+
9+
# Authors: The MNE-Python contributors.
10+
# License: BSD-3-Clause
11+
# Copyright the MNE-Python contributors
12+
13+
import os
14+
15+
import numpy as np
16+
17+
_MAX_CACHE = 16
18+
_cache = {}
19+
20+
21+
def get_u8_memmap(path):
22+
"""Return a uint8 memmap of *path* (PID-keyed), or None on any failure."""
23+
try:
24+
st = os.stat(path)
25+
key = (os.getpid(), str(path))
26+
hit = _cache.get(key)
27+
if hit is not None:
28+
mm, mtime_ns, size = hit
29+
if mtime_ns == st.st_mtime_ns and size == st.st_size:
30+
return mm
31+
_cache.pop(key, None)
32+
mm = np.memmap(str(path), dtype=np.uint8, mode="r")
33+
except Exception:
34+
return None
35+
_cache[key] = (mm, st.st_mtime_ns, st.st_size)
36+
while len(_cache) > _MAX_CACHE:
37+
_cache.pop(next(iter(_cache)))
38+
return mm

mne/_fiff/utils.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,15 @@ def _mult_cal_one(data_view, one, idx, cals, mult):
8484
else:
8585
assert cals is not None
8686
if isinstance(idx, slice):
87-
# Hot path: gather + type-cast + calibration in a single pass
88-
# (was three passes plus a full float64 temporary).
89-
# Benchmark (128 ch x 1024 samples): ~85 -> ~30 us per call
90-
# on BrainVision/FIF window reads.
87+
# Hot path: gather + type-cast + calibration in a single pass,
88+
# without materializing an intermediate float64 copy of `one`
89+
# (`one[idx]` is a view for basic slices). Numerically identical
90+
# to cast-then-scale because both are elementwise.
9191
np.multiply(one[idx], cals.reshape(-1, 1), out=data_view,
9292
casting="unsafe")
9393
else:
9494
one = np.asarray(one, dtype=data_view.dtype)
95+
# faster than doing one = one[idx]
9596
np.take(one, idx, axis=0, out=data_view)
9697
data_view *= cals
9798

@@ -220,6 +221,8 @@ def _read_segments_file(
220221
if n_channels is None:
221222
n_channels = raw._raw_extras[fi]["orig_nchan"]
222223

224+
import os as _os
225+
223226
n_bytes = np.dtype(dtype).itemsize
224227
# data_offset and data_left count data samples (channels x time points),
225228
# not bytes.
@@ -229,6 +232,49 @@ def _read_segments_file(
229232
# Read up to 100 MB of data at a time, block_size is in data samples
230233
block_size = ((int(100e6) // n_bytes) // n_channels) * n_channels
231234
block_size = min(data_left, block_size)
235+
236+
# Reuse a memory map across calls (keyed by PID so forked processes --
237+
# e.g., PyTorch DataLoader workers -- create their own mapping instead of
238+
# sharing one). This removes the per-call open/seek/syscall overhead.
239+
ex = raw._raw_extras[fi] if fi < len(raw._raw_extras) else {}
240+
mm = ex.get("_mm") if isinstance(ex, dict) else None
241+
if mm is not None and ex.get("_mm_pid") != _os.getpid():
242+
mm = None
243+
if mm is not None and (
244+
mm.dtype != np.dtype(dtype) or mm.size * n_bytes < data_offset + data_left * n_bytes
245+
):
246+
mm = None
247+
if mm is None and isinstance(ex, dict):
248+
try:
249+
mm = np.memmap(raw.filenames[fi], dtype=dtype, mode="r")
250+
ex["_mm"] = mm
251+
ex["_mm_pid"] = _os.getpid()
252+
except Exception:
253+
mm = None
254+
255+
if mm is not None:
256+
base_idx = data_offset // n_bytes
257+
for sample_start in np.arange(0, data_left, block_size) // n_channels:
258+
count = min(block_size, data_left - sample_start * n_channels)
259+
block = mm[
260+
base_idx + sample_start * n_channels :
261+
base_idx + sample_start * n_channels + count
262+
]
263+
if block.size != count:
264+
raise RuntimeError(
265+
f"Incorrect number of samples ({block.size} != {count}), "
266+
"please report this error to MNE-Python developers"
267+
)
268+
block = block.reshape(n_channels, -1, order="F")
269+
n_samples = block.shape[1]
270+
sample_stop = sample_start + n_samples
271+
if trigger_ch is not None:
272+
stim_ch = trigger_ch[start:stop][sample_start:sample_stop]
273+
block = np.vstack((block, stim_ch))
274+
data_view = data[:, sample_start:sample_stop]
275+
_mult_cal_one(data_view, block, idx, cals, mult)
276+
return
277+
232278
with open(raw.filenames[fi], "rb", buffering=0) as fid:
233279
fid.seek(data_offset)
234280
# extract data in chunks

mne/io/fiff/raw.py

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99

1010
import numpy as np
1111

12+
from ..._fiff._mmap_cache import get_u8_memmap
1213
from ..._fiff.constants import FIFF
1314
from ..._fiff.meas_info import read_meas_info
1415
from ..._fiff.open import _fiff_get_fid, _get_next_fname, fiff_open
15-
from ..._fiff.tag import _call_dict, read_tag
16+
from ..._fiff.tag import _call_dict, _simple_dict, read_tag
1617
from ..._fiff.tree import dir_tree_find
1718
from ..._fiff.utils import _mult_cal_one
1819
from ...annotations import Annotations, _read_annotations_fif
@@ -403,13 +404,84 @@ def _dtype(self):
403404
def _read_segment_file(self, data, idx, fi, start, stop, cals, mult):
404405
"""Read a segment of data from a file."""
405406
n_bad = 0
406-
with _fiff_get_fid(self._raw_extras[fi]["filename"]) as fid:
407-
bounds = self._raw_extras[fi]["bounds"]
408-
ents = self._raw_extras[fi]["ent"]
409-
nchan = self._raw_extras[fi]["orig_nchan"]
410-
use = (stop > bounds[:-1]) & (start < bounds[1:])
407+
bounds = self._raw_extras[fi]["bounds"]
408+
ents = self._raw_extras[fi]["ent"]
409+
nchan = self._raw_extras[fi]["orig_nchan"]
410+
fname = self._raw_extras[fi]["filename"]
411+
# Entries overlapping [start, stop) via binary search on sorted bounds
412+
# (O(log n) instead of a mask over every entry; matters for long
413+
# recordings with thousands of buffer entries).
414+
eis = range(
415+
max(np.searchsorted(bounds, start, side="right") - 1, 0),
416+
min(np.searchsorted(bounds, stop, side="left"), len(bounds) - 1),
417+
)
418+
419+
# Fast path: read tag payloads directly through a PID-keyed memory map,
420+
# skipping per-call open/seek/read syscalls. Only taken for uncompressed
421+
# real files whose touched tags are simple numeric types with the
422+
# expected sizes; everything else falls back to the legacy loop below.
423+
mm = None
424+
if (
425+
isinstance(fname, Path)
426+
and len(fname.suffixes) > 0
427+
and fname.suffixes[-1] != ".gz"
428+
):
429+
mm = get_u8_memmap(fname)
430+
if mm is not None:
431+
for ei in eis:
432+
ent = ents[ei]
433+
if ent is None or ent.type not in _simple_dict:
434+
mm = None
435+
break
436+
nsamp_ei = bounds[ei + 1] - bounds[ei]
437+
itemsize = np.dtype(_simple_dict[ent.type]).itemsize
438+
if getattr(ent, "size", None) != nsamp_ei * nchan * itemsize:
439+
mm = None
440+
break
441+
if mm is not None:
442+
offset = 0
443+
for ei in eis:
444+
first = bounds[ei]
445+
last = bounds[ei + 1]
446+
nsamp = last - first
447+
ent = ents[ei]
448+
first_pick = max(start - first, 0)
449+
last_pick = min(nsamp, stop - first)
450+
picksamp = last_pick - first_pick
451+
this_start = offset
452+
offset += picksamp
453+
this_stop = offset
454+
if ent is None:
455+
continue # gaps were zero-initialized by the caller
456+
dtype_s = _simple_dict[ent.type]
457+
itemsize = np.dtype(dtype_s).itemsize
458+
nbytes = picksamp * nchan * itemsize
459+
base = ent.pos + 16 + first_pick * nchan * itemsize
460+
one = np.frombuffer(
461+
mm[base : base + nbytes], dtype=dtype_s, count=picksamp * nchan
462+
)
463+
if one.size != picksamp * nchan:
464+
n_bad += picksamp
465+
continue
466+
one = one.reshape(picksamp, nchan)
467+
_mult_cal_one(
468+
data[:, this_start:this_stop],
469+
one.T,
470+
idx,
471+
cals,
472+
mult,
473+
)
474+
if n_bad:
475+
warn(
476+
f"FIF raw buffer could not be read, acquisition error "
477+
f"likely: {n_bad} samples set to zero"
478+
)
479+
assert offset == stop - start
480+
return
481+
482+
with _fiff_get_fid(fname) as fid:
411483
offset = 0
412-
for ei in np.where(use)[0]:
484+
for ei in eis:
413485
first = bounds[ei]
414486
last = bounds[ei + 1]
415487
nsamp = last - first

0 commit comments

Comments
 (0)