Skip to content

Commit 990b264

Browse files
Merge remote-tracking branch 'upstream/main' into perf/raw-io-cold-path
# Conflicts: # mne/io/base.py # mne/io/tests/test_raw.py
2 parents ea3b9ae + 6471846 commit 990b264

15 files changed

Lines changed: 359 additions & 21 deletions

File tree

doc/changes/dev/14216.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix bug where :func:`mne.io.read_raw_ant` and :func:`mne.io.read_raw_curry` ignored projections and non-boolean ``preload`` values respectively when reading data lazily, by `Bruno Aristimunha`_.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Speed up repeated preloading of any file-backed :class:`~mne.io.Raw` with ``preload="auto"``, which persists decoded data in a copy-on-write cache below :func:`mne.set_cache_dir`, by `Bruno Aristimunha`_.

mne/channels/channels.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,7 @@ def add_channels(self, add_list, force_update_info=False):
748748
# Now update the attributes
749749
if (
750750
isinstance(self._data, np.memmap)
751+
and self._data.mode != "c"
751752
and con_axis == 0
752753
and sys.platform != "darwin"
753754
): # resizing not available--no mremap

mne/io/_preload_cache.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Persistent decoded-data cache for Raw readers."""
2+
3+
# Authors: The MNE-Python contributors.
4+
# License: BSD-3-Clause
5+
# Copyright the MNE-Python contributors.
6+
7+
import hashlib
8+
import os
9+
import pickle
10+
from pathlib import Path
11+
12+
import numpy as np
13+
14+
from .. import __version__ as MNE_VERSION # ty: ignore[unresolved-import]
15+
from ..utils import get_config, logger
16+
17+
_RAW_PRELOAD_CACHE_VERSION = 1
18+
19+
20+
def _raw_preload_cache_info(raw):
21+
"""Return the cache path and decoded array description."""
22+
cache_root = get_config("MNE_CACHE_DIR", None)
23+
if cache_root is None:
24+
raise ValueError(
25+
'preload="auto" requires a configured cache directory; use '
26+
"mne.set_cache_dir(...) first"
27+
)
28+
cache_dir = Path(cache_root).expanduser().resolve()
29+
cache_dir = cache_dir / f"raw-preload-v{_RAW_PRELOAD_CACHE_VERSION}"
30+
cache_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
31+
32+
sources = []
33+
for filename in raw.filenames:
34+
if filename is None:
35+
raise ValueError(
36+
'preload="auto" requires stable source files; use preload=True '
37+
"or an explicit memory-map path"
38+
)
39+
path = Path(filename).resolve(strict=True)
40+
# some formats (e.g., CTF) name a directory rather than a single file
41+
members = sorted(path.rglob("*")) if path.is_dir() else [path]
42+
for member in members:
43+
if not member.is_file():
44+
continue
45+
result = member.stat()
46+
sources.append((str(member), int(result.st_size), int(result.st_mtime_ns)))
47+
48+
dtype = np.dtype(raw._dtype)
49+
shape = (int(raw.info["nchan"]), int(raw.n_times))
50+
identity = (
51+
_RAW_PRELOAD_CACHE_VERSION,
52+
MNE_VERSION,
53+
type(raw).__module__,
54+
type(raw).__qualname__,
55+
sources,
56+
raw._raw_extras,
57+
raw._cals,
58+
dtype.str,
59+
shape,
60+
)
61+
try:
62+
key = hashlib.sha256(pickle.dumps(identity, protocol=5)).hexdigest()
63+
except Exception as exc:
64+
raise ValueError(
65+
f'preload="auto" cannot identify this {type(raw).__name__} source'
66+
) from exc
67+
return cache_dir / f"{key}.data", sources, shape, dtype
68+
69+
70+
def _raw_preload_cache_read(path, shape, dtype):
71+
"""Map a complete decoded-data cache entry."""
72+
try:
73+
nbytes = int(np.prod(shape, dtype=np.int64)) * dtype.itemsize
74+
if path.stat().st_size != nbytes:
75+
return None
76+
return np.memmap(path, mode="c", dtype=dtype, shape=shape)
77+
except OSError:
78+
return None
79+
80+
81+
def _raw_preload_auto(raw):
82+
"""Reuse or create an automatic decoded-data cache entry."""
83+
path, sources, shape, dtype = _raw_preload_cache_info(raw)
84+
data = _raw_preload_cache_read(path, shape, dtype)
85+
if data is not None:
86+
logger.info(f"Reusing decoded data from {path}")
87+
return data
88+
89+
# The temporary is per-process and os.replace is atomic, so concurrent
90+
# misses need no lock; they at worst decode the same entry twice.
91+
logger.info(f"Creating decoded data cache in {path.parent}")
92+
temporary = path.with_suffix(f".{os.getpid()}.tmp")
93+
try:
94+
data = np.memmap(temporary, mode="w+", dtype=dtype, shape=shape)
95+
try:
96+
raw._read_segment(data_buffer=data)
97+
data.flush()
98+
finally:
99+
data._mmap.close() # ty: ignore[unresolved-attribute]
100+
if _raw_preload_cache_info(raw)[1] != sources:
101+
raise RuntimeError(
102+
"Source data changed while decoded cache was created; retry"
103+
)
104+
try:
105+
os.replace(temporary, path)
106+
except OSError:
107+
# Windows refuses to replace an entry another process already mapped.
108+
pass
109+
finally:
110+
temporary.unlink(missing_ok=True)
111+
data = _raw_preload_cache_read(path, shape, dtype)
112+
if data is None:
113+
raise RuntimeError(f"Could not read back the decoded data cache at {path}")
114+
return data

mne/io/ant/ant.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from ..._fiff.constants import FIFF
1313
from ..._fiff.meas_info import create_info
14+
from ..._fiff.utils import _mult_cal_one
1415
from ...annotations import Annotations
1516
from ...utils import (
1617
_check_fname,
@@ -185,11 +186,7 @@ def _read_segment_file(self, data, idx, fi, start, stop, cals, mult):
185186
one = read_data(cnt, i_start, i_stop)
186187
_scale_data(one, ch_units)
187188
data_view = data[:, i_start - start : i_stop - start]
188-
if isinstance(idx, slice):
189-
data_view[:] = one[idx]
190-
else:
191-
# faster than doing one = one[idx]
192-
np.take(one, idx, axis=0, out=data_view)
189+
_mult_cal_one(data_view, one, idx, cals, mult)
193190

194191

195192
def _handle_bipolar_channels(

mne/io/ant/tests/test_ant.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from mne.datasets import testing
1515
from mne.io import BaseRaw, read_raw, read_raw_ant, read_raw_brainvision
1616
from mne.io.ant.ant import RawANT
17+
from mne.io.tests.test_raw import _test_raw_reader
1718

1819
pytest.importorskip("antio", minversion="0.5.0")
1920
data_path = testing.data_path(download=False) / "antio"
@@ -428,6 +429,12 @@ def test_annotations_and_preload(ca_208: TypeDataset):
428429
assert raw_cnt.annotations.description[0] == "impedance"
429430

430431

432+
@testing.requires_testing_data
433+
def test_ant_raw_reader(ca_208: TypeDataset):
434+
"""Test the generic reader checks, including projected lazy reads."""
435+
_test_raw_reader(read_raw_ant, fname=ca_208["cnt"]["short"])
436+
437+
431438
@testing.requires_testing_data
432439
def test_read_raw(ca_208: TypeDataset):
433440
"""Test loading through read_raw."""

mne/io/base.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
warn,
104104
)
105105
from ..utils._typing import Color, Self
106+
from ._preload_cache import _raw_preload_auto
106107

107108
if TYPE_CHECKING:
108109
# Heavy/optional deps kept out of the runtime import path (see
@@ -143,8 +144,11 @@ class BaseRaw(
143144
freshly created memory-mapped file used to store the data on the hard
144145
drive (slower, requires less memory). An existing file is overwritten.
145146
The caller owns the file and is responsible for removing it after the
146-
Raw object is no longer in use. If preload is an ndarray, the data are
147-
taken from that array. If False, data are not read until save.
147+
Raw object is no longer in use. For supported file readers, the exact
148+
string ``"auto"`` instead reuses decoded data below the directory
149+
configured by :func:`mne.set_cache_dir`. Use ``Path("auto")`` for a
150+
literal filename. If preload is an ndarray, the data are taken from that
151+
array. If False, data are not read until save.
148152
first_samps : sequence
149153
Sequence of the first sample number from each raw file. For unsplit raw
150154
files this should be a length-one list or tuple.
@@ -601,11 +605,14 @@ def load_data(
601605
602606
Parameters
603607
----------
604-
memmap : path-like | None
608+
memmap : path-like | str | None
605609
If not ``None``, preload data into a freshly created memory-mapped file
606610
at this path. An existing file is overwritten. The caller owns the file
607611
and is responsible for removing it after the Raw object is no longer in
608-
use. If ``None`` (default), preload data into RAM.
612+
use. The exact string ``"auto"`` instead means the same as
613+
``preload="auto"``: reuse decoded data below the directory configured by
614+
:func:`mne.set_cache_dir`. Use ``Path("auto")`` for a literal filename.
615+
If ``None`` (default), preload data into RAM.
609616
610617
.. versionadded:: 1.13
611618
%(verbose)s
@@ -623,13 +630,23 @@ def load_data(
623630
.. versionadded:: 0.10.0
624631
"""
625632
if not self.preload:
626-
if memmap is not None:
633+
if isinstance(memmap, str) and memmap == "auto":
634+
pass # sentinel, resolved in _preload_data
635+
elif memmap is not None:
627636
_validate_type(memmap, "path-like", "memmap")
637+
memmap = Path(memmap)
628638
self._preload_data(memmap if memmap is not None else True)
629639
return self
630640

631641
def _preload_data(self, preload):
632642
"""Actually preload the data."""
643+
if isinstance(preload, str) and preload == "auto":
644+
self._data = _raw_preload_auto(self)
645+
assert len(self._data) == self.info["nchan"]
646+
self.preload = True
647+
self._comp = None
648+
self.close()
649+
return
633650
data_buffer = preload
634651
if isinstance(preload, bool | np.bool_) and not preload:
635652
data_buffer = None
@@ -794,6 +811,8 @@ def set_annotations(
794811
"of the raw object."
795812
)
796813

814+
# This is algebraically ``self.times[-1] + 1 / sfreq`` without
815+
# allocating the full time vector for large file-backed recordings.
797816
sfreq = self.info["sfreq"]
798817
annotation_end = (self.n_times - 1) / sfreq + 1.0 / sfreq
799818
new_annotations = annotations.copy()

mne/io/curry/curry.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -807,8 +807,9 @@ def __init__(self, fname, preload=False, on_bad_hpi_match="warn", verbose=None):
807807

808808
# scale data to SI units
809809
self._cals = np.array(cals)
810-
if isinstance(preload, bool | np.bool_) and preload:
811-
self.load_data()
810+
if not isinstance(preload, bool | np.bool_) or preload:
811+
# preload can also be a memory-map path or the "auto" sentinel
812+
self._preload_data(preload)
812813

813814
# set events / annotations
814815
# format from curryreader: sample, etype, startsample, endsample

mne/io/fiff/raw.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ def __init__(
9898
on_split_missing: str = "raise",
9999
verbose: bool | str | int | None = None,
100100
):
101+
if isinstance(preload, str) and preload == "auto":
102+
if _file_like(fname):
103+
raise ValueError(
104+
'preload="auto" requires stable source files and is not '
105+
"supported for file-like FIF inputs"
106+
)
101107
raws = []
102108
do_check_ext = not _file_like(fname)
103109
next_fname = fname
@@ -198,7 +204,9 @@ def _read_raw_file(
198204
check_fname(fname, "raw", endings)
199205
# filename
200206
fname = _check_fname(fname, "read", True, "fname")
201-
whole_file = preload if fname.suffix == ".gz" else False
207+
whole_file = (
208+
preload if preload != "auto" and fname.suffix == ".gz" else False
209+
)
202210
else:
203211
# file-like
204212
if not preload:

mne/io/fiff/tests/test_raw_fiff.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2120,6 +2120,15 @@ def test_file_like(kind, preload, split, tmp_path):
21202120
assert file_fid.closed
21212121

21222122

2123+
def test_file_like_auto_preload_rejected(tmp_path, monkeypatch):
2124+
"""Test that automatic caching cannot misidentify a named stream."""
2125+
monkeypatch.setenv("MNE_CACHE_DIR", str(tmp_path))
2126+
stream = BytesIO(test_fif_fname.read_bytes())
2127+
stream.name = str(test_fif_fname)
2128+
with pytest.raises(ValueError, match="stable source files"):
2129+
read_raw_fif(stream, preload="auto")
2130+
2131+
21232132
def test_str_like():
21242133
"""Test handling with str-like objects."""
21252134
fname = pathlib.Path(test_fif_fname)

0 commit comments

Comments
 (0)