Skip to content

Commit 9c46efe

Browse files
Fix memory-map reader fallbacks and lifecycle
1 parent 53be940 commit 9c46efe

6 files changed

Lines changed: 177 additions & 74 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Speed up FIF reading by reading tag payloads through a memory map instead of open/seek/read per call, by `Bruno Aristimunha`_

mne/_fiff/tests/test_utils.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@
44
# License: BSD-3-Clause
55
# Copyright the MNE-Python contributors.
66

7-
from mne._fiff.utils import _check_orig_units
7+
import pickle
8+
from copy import deepcopy
9+
from types import SimpleNamespace
10+
from unittest.mock import Mock
11+
12+
import numpy as np
13+
from numpy.testing import assert_array_equal
14+
15+
from mne._fiff.utils import _check_orig_units, _memmap_for, _read_segments_file
816

917

1018
def test_check_orig_units():
@@ -16,3 +24,75 @@ def test_check_orig_units():
1624
assert orig_units["Pz"] == "µV"
1725
assert orig_units["greekMu"] == "µV"
1826
assert orig_units["microSign"] == "µV"
27+
28+
29+
def test_read_segments_file_unaligned_offset(tmp_path):
30+
"""Test reading multi-byte data at an unaligned byte offset."""
31+
fname = tmp_path / "test.bin"
32+
values = np.array([[1, 2, 3], [10, 20, 30]], dtype="<i2")
33+
fname.write_bytes(b"x" + values.T.tobytes() + b"x")
34+
raw = SimpleNamespace(
35+
_raw_extras=[dict(orig_nchan=2)],
36+
filenames=[fname],
37+
)
38+
data = np.empty((2, 3))
39+
40+
_read_segments_file(
41+
raw,
42+
data,
43+
slice(None),
44+
0,
45+
0,
46+
3,
47+
np.ones(2),
48+
None,
49+
dtype="<i2",
50+
offset=1,
51+
)
52+
53+
assert_array_equal(data, values)
54+
55+
56+
def test_memmap_cache_deepcopy(tmp_path):
57+
"""Test that copying a cache does not copy the mapped file into memory."""
58+
fname = tmp_path / "test.bin"
59+
fname.write_bytes(b"test")
60+
extras = {}
61+
original = _memmap_for(extras, fname)
62+
63+
copied = _memmap_for(deepcopy(extras), fname)
64+
65+
assert copied is not original
66+
assert copied.filename == str(fname)
67+
assert not copied.flags.writeable
68+
69+
70+
def test_memmap_cache_pickle(tmp_path):
71+
"""Test that pickling a cache does not serialize the mapped file."""
72+
fname = tmp_path / "test.bin"
73+
fname.write_bytes(b"test")
74+
extras = {}
75+
original = _memmap_for(extras, fname)
76+
77+
copied = _memmap_for(pickle.loads(pickle.dumps(extras)), fname)
78+
79+
assert copied is not original
80+
assert copied.filename == str(fname)
81+
assert not copied.flags.writeable
82+
83+
84+
def test_memmap_cache_failed_pid_change(tmp_path, monkeypatch):
85+
"""Test that a failed process-local remap releases inherited state."""
86+
fname = tmp_path / "test.bin"
87+
fname.write_bytes(b"test")
88+
extras = {}
89+
original = _memmap_for(extras, fname)
90+
cache = extras["_memmap_cache"]
91+
92+
monkeypatch.setattr("mne._fiff.utils.os.getpid", lambda: cache.pid + 1)
93+
monkeypatch.setattr(np, "memmap", Mock(side_effect=OSError))
94+
95+
assert _memmap_for(extras, fname) is None
96+
assert original._mmap.closed
97+
assert cache.mapping is None
98+
assert cache.pid is None

mne/_fiff/utils.py

Lines changed: 49 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,6 @@ def _read_segments_file(
220220
if n_channels is None:
221221
n_channels = raw._raw_extras[fi]["orig_nchan"]
222222

223-
import os as _os
224-
225223
n_bytes = np.dtype(dtype).itemsize
226224
# data_offset and data_left count data samples (channels x time points),
227225
# not bytes.
@@ -235,32 +233,18 @@ def _read_segments_file(
235233
# Reuse a memory map across calls (keyed by PID so forked processes --
236234
# e.g., PyTorch DataLoader workers -- create their own mapping instead of
237235
# sharing one). This removes the per-call open/seek/syscall overhead.
238-
ex = raw._raw_extras[fi] if fi < len(raw._raw_extras) else {}
239-
mm = ex.get("_mm") if isinstance(ex, dict) else None
240-
if mm is not None and ex.get("_mm_pid") != _os.getpid():
241-
mm = None
242-
if mm is not None and (
243-
mm.dtype != np.dtype(dtype)
244-
or mm.size * n_bytes < data_offset + data_left * n_bytes
245-
):
236+
extras = raw._raw_extras[fi] if fi < len(raw._raw_extras) else {}
237+
mm = _memmap_for(extras, raw.filenames[fi]) if isinstance(extras, dict) else None
238+
if mm is not None and mm.size < data_offset + data_left * n_bytes:
246239
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
254240

255241
if mm is not None:
256-
base_idx = data_offset // n_bytes
257242
for sample_start in np.arange(0, data_left, block_size) // n_channels:
258243
count = min(block_size, data_left - sample_start * n_channels)
259-
block = mm[
260-
base_idx + sample_start * n_channels : base_idx
261-
+ sample_start * n_channels
262-
+ count
263-
]
244+
byte_start = data_offset + sample_start * n_channels * n_bytes
245+
block = np.frombuffer(
246+
mm[byte_start : byte_start + count * n_bytes], dtype=dtype, count=count
247+
)
264248
if block.size != count:
265249
raise RuntimeError(
266250
f"Incorrect number of samples ({block.size} != {count}), "
@@ -383,23 +367,52 @@ def _make_split_fnames(fname, n_splits, split_naming):
383367
return res
384368

385369

370+
class _MemmapCache:
371+
"""Hold a memory map without copying or pickling its contents."""
372+
373+
def __init__(self):
374+
self.mapping = None
375+
self.pid = None
376+
377+
def __deepcopy__(self, memodict):
378+
"""Create an empty cache when its owner is copied."""
379+
return type(self)()
380+
381+
def __reduce__(self):
382+
"""Create an empty cache when its owner is pickled."""
383+
return type(self), ()
384+
385+
def close(self):
386+
"""Close the mapping and reset the cache."""
387+
mapping = self.mapping
388+
self.mapping = self.pid = None
389+
if mapping is not None:
390+
mapping._mmap.close()
391+
392+
386393
def _memmap_for(extras, fname):
387-
"""Return a PID-keyed read-only uint8 memmap of *fname* from *extras*.
388-
389-
The mapping is created lazily on first call and cached in *extras* (a
390-
per-instance dict) together with the PID that created it, so forked worker
391-
processes build their own mapping instead of sharing inherited state.
392-
Returns None if the file cannot be mapped. There is deliberately no
393-
staleness check: callers index the mapping through tables read at open
394-
time (bounds, entries), which are invalid if the file changes anyway,
395-
so a per-call stat would only add overhead.
394+
"""Return a process-local read-only uint8 memmap of *fname* from *extras*.
395+
396+
The mapping is created lazily on first call and held by a cache that is
397+
reset when *extras* is copied or pickled. The creating PID is recorded so
398+
forked worker processes build their own mapping instead of sharing
399+
inherited state. Returns None if the file cannot be mapped. There is
400+
deliberately no staleness check: callers index the mapping through tables
401+
read at open time (bounds, entries), which are invalid if the file changes
402+
anyway, so a per-call stat would only add overhead.
396403
"""
397-
mm = extras.get("_mm")
398-
if mm is not None and extras.get("_mm_pid") == os.getpid():
399-
return mm
404+
cache = extras.get("_memmap_cache")
405+
if cache is None:
406+
cache = extras["_memmap_cache"] = _MemmapCache()
407+
mm = cache.mapping
408+
pid = os.getpid()
409+
if mm is not None:
410+
if cache.pid == pid:
411+
return mm
412+
cache.close()
400413
try:
401414
mm = np.memmap(str(fname), dtype=np.uint8, mode="r")
402415
except Exception:
403416
return None
404-
extras["_mm"], extras["_mm_pid"] = mm, os.getpid()
417+
cache.mapping, cache.pid = mm, pid
405418
return mm

mne/io/base.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2321,12 +2321,11 @@ def append(
23212321
raise RuntimeError("Append error") # should never happen
23222322

23232323
def close(self) -> None:
2324-
"""Clean up the object.
2325-
2326-
Does nothing for objects that close their file descriptors.
2327-
Things like Raw will override this method.
2328-
"""
2329-
pass # noqa
2324+
"""Clean up any resources used by the object."""
2325+
for extras in self._raw_extras:
2326+
cache = extras.get("_memmap_cache")
2327+
if cache is not None:
2328+
cache.close()
23302329

23312330
def copy(self) -> Self:
23322331
"""Return copy of the instance.

mne/io/fiff/raw.py

Lines changed: 26 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@
3838
)
3939

4040

41-
@fill_doc
42-
def _fif_mm_plan(bounds, ents, entry_span, nchan, file_mapping, start, stop):
41+
def _fif_mm_plan(bounds, ents, entry_span, nchan, start, stop):
4342
"""Plan byte-offset reads covering [start, stop) from a FIF memory map.
4443
4544
Returns a list of ``(dtype, byte_offset, n_bytes, n_samples)`` read tuples,
@@ -73,6 +72,7 @@ def _fif_mm_plan(bounds, ents, entry_span, nchan, file_mapping, start, stop):
7372
return read_plan
7473

7574

75+
@fill_doc
7676
class Raw(BaseRaw):
7777
"""Raw data in FIF format.
7878
@@ -458,34 +458,30 @@ def _read_segment_file(self, data, idx, fi, start, stop, cals, mult):
458458

459459
# Fast path: serve [start, stop) from one persistent memory map of
460460
# the file; see _fif_mm_plan() for eligibility and fallbacks.
461-
fname = self._raw_extras[fi]["filename"]
462-
file_mapping = (
463-
_memmap_for(self._raw_extras[fi], fname)
464-
if isinstance(fname, Path) and fname.suffixes[-1] != ".gz"
465-
else None
466-
)
467-
read_plan = (
468-
_fif_mm_plan(bounds, ents, entry_span, nchan, file_mapping, start, stop)
469-
if file_mapping is not None
470-
else None
471-
)
472-
if read_plan is not None:
473-
col_start = 0
474-
for dtype, byte_offset, n_bytes, n_samples in read_plan:
475-
values = np.frombuffer(
476-
file_mapping[byte_offset : byte_offset + n_bytes],
477-
dtype=dtype,
478-
count=n_samples * nchan,
479-
).reshape(n_samples, nchan)
480-
_mult_cal_one(
481-
data[:, col_start : col_start + n_samples],
482-
values.T,
483-
idx,
484-
cals,
485-
mult,
486-
)
487-
col_start += n_samples
488-
return
461+
read_plan = _fif_mm_plan(bounds, ents, entry_span, nchan, start, stop)
462+
if (
463+
read_plan is not None
464+
and isinstance(fname, Path)
465+
and fname.suffixes[-1] != ".gz"
466+
):
467+
file_mapping = _memmap_for(self._raw_extras[fi], fname)
468+
if file_mapping is not None:
469+
col_start = 0
470+
for dtype, byte_offset, n_bytes, n_samples in read_plan:
471+
values = np.frombuffer(
472+
file_mapping[byte_offset : byte_offset + n_bytes],
473+
dtype=dtype,
474+
count=n_samples * nchan,
475+
).reshape(n_samples, nchan)
476+
_mult_cal_one(
477+
data[:, col_start : col_start + n_samples],
478+
values.T,
479+
idx,
480+
cals,
481+
mult,
482+
)
483+
col_start += n_samples
484+
return
489485

490486
with _fiff_get_fid(fname) as fid:
491487
offset = 0

mne/io/tests/test_raw.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from mne._fiff.meas_info import Info, _get_valid_units, _writing_info_hdf5
2828
from mne._fiff.pick import _ELECTRODE_CH_TYPES, _FNIRS_CH_TYPES_SPLIT
2929
from mne._fiff.proj import Projection
30-
from mne._fiff.utils import _mult_cal_one
30+
from mne._fiff.utils import _memmap_for, _mult_cal_one
3131
from mne.io import BaseRaw, RawArray, read_raw_fif
3232
from mne.io.base import _get_scaling
3333
from mne.transforms import Transform
@@ -838,6 +838,20 @@ def _read_raw_arange(preload=False, verbose=None):
838838
return _RawArange(preload, verbose)
839839

840840

841+
def test_raw_close_memmap_cache(tmp_path):
842+
"""Test that closing Raw releases cached memory maps."""
843+
fname = tmp_path / "test.bin"
844+
fname.write_bytes(b"test")
845+
with _read_raw_arange() as raw:
846+
cache = raw._raw_extras[0]
847+
mapping = _memmap_for(cache, fname)
848+
assert not mapping._mmap.closed
849+
850+
assert mapping._mmap.closed
851+
assert cache["_memmap_cache"].mapping is None
852+
assert cache["_memmap_cache"].pid is None
853+
854+
841855
def test_load_data_memmap(tmp_path):
842856
"""Test loading raw data into a memmap via load_data."""
843857
raw = _read_raw_arange(preload=False)

0 commit comments

Comments
 (0)