Skip to content

Commit 8e0a247

Browse files
perf: keep the Raw preload cache lock-free and lift the "auto" sentinel
Publish cache entries through a per-process temporary and os.replace instead of a FileLock, so filelock stays an optional dependency rather than a required one. Make load_data(memmap="auto") resolve the same sentinel as preload="auto"; previously it meant a file literally named "auto". Stop reusing a live-mapped filename in test_auto_preload_api, which Windows rejects when truncating a file that still has a mapping.
1 parent a138fb7 commit 8e0a247

7 files changed

Lines changed: 38 additions & 40 deletions

File tree

README.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ The minimum required dependencies to run MNE-Python are:
7474
7575
- `Python <https://www.python.org>`__ ≥ 3.11
7676
- `decorator <https://pypi.org/project/decorator/>`__ ≥ 5.1
77-
- `filelock <https://github.com/tox-dev/py-filelock>`__ ≥ 3.18.0
7877
- `Jinja2 <https://jinja.palletsprojects.com/>`__ ≥ 3.1
7978
- `lazy-loader <https://pypi.org/project/lazy-loader/>`__ ≥ 0.3
8079
- `Matplotlib <https://matplotlib.org>`__ ≥ 3.9
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
Speed up repeated preloading of FIF, EDF/BDF, and BrainVision
2-
recordings with a persistent copy-on-write decoded-data cache, by `Bruno Aristimunha`_.
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/io/_preload_cache.py

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,9 @@
1212
import numpy as np
1313

1414
from .. import __version__ as MNE_VERSION # ty: ignore[unresolved-import]
15-
from ..utils import _soft_import, get_config, logger
15+
from ..utils import get_config, logger
1616

1717
_RAW_PRELOAD_CACHE_VERSION = 1
18-
_RAW_PRELOAD_LOCK_TIMEOUT = 300.0
1918

2019

2120
def _raw_preload_cache_info(raw):
@@ -82,27 +81,29 @@ def _raw_preload_auto(raw):
8281
logger.info(f"Reusing decoded data from {path}")
8382
return data
8483

85-
# Importing filelock is measurable, so keep it off the cache-hit path.
86-
filelock = _soft_import("filelock", "locking the decoded-data cache")
87-
with filelock.FileLock(f"{path}.lock", timeout=_RAW_PRELOAD_LOCK_TIMEOUT):
88-
data = _raw_preload_cache_read(path, shape, dtype)
89-
if data is None:
90-
logger.info(f"Creating decoded data cache in {path.parent}")
91-
temporary = path.with_suffix(".tmp")
92-
try:
93-
temporary.unlink(missing_ok=True)
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-
os.replace(temporary, path)
105-
finally:
106-
temporary.unlink(missing_ok=True)
107-
data = _raw_preload_cache_read(path, shape, dtype)
84+
# The temporary is per-process and os.replace is atomic, so concurrent
85+
# misses need no lock; they at worst decode the same entry twice.
86+
logger.info(f"Creating decoded data cache in {path.parent}")
87+
temporary = path.with_suffix(f".{os.getpid()}.tmp")
88+
try:
89+
data = np.memmap(temporary, mode="w+", dtype=dtype, shape=shape)
90+
try:
91+
raw._read_segment(data_buffer=data)
92+
data.flush()
93+
finally:
94+
data._mmap.close() # ty: ignore[unresolved-attribute]
95+
if _raw_preload_cache_info(raw)[1] != sources:
96+
raise RuntimeError(
97+
"Source data changed while decoded cache was created; retry"
98+
)
99+
try:
100+
os.replace(temporary, path)
101+
except OSError:
102+
# Windows refuses to replace an entry another process already mapped.
103+
pass
104+
finally:
105+
temporary.unlink(missing_ok=True)
106+
data = _raw_preload_cache_read(path, shape, dtype)
107+
if data is None:
108+
raise RuntimeError(f"Could not read back the decoded data cache at {path}")
108109
return data

mne/io/base.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -605,11 +605,14 @@ def load_data(
605605
606606
Parameters
607607
----------
608-
memmap : path-like | None
608+
memmap : path-like | str | None
609609
If not ``None``, preload data into a freshly created memory-mapped file
610610
at this path. An existing file is overwritten. The caller owns the file
611611
and is responsible for removing it after the Raw object is no longer in
612-
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.
613616
614617
.. versionadded:: 1.13
615618
%(verbose)s
@@ -627,7 +630,9 @@ def load_data(
627630
.. versionadded:: 0.10.0
628631
"""
629632
if not self.preload:
630-
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:
631636
_validate_type(memmap, "path-like", "memmap")
632637
memmap = Path(memmap)
633638
self._preload_data(memmap if memmap is not None else True)

mne/io/tests/test_preload_cache.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,10 @@ def test_auto_preload_api(tmp_path, monkeypatch):
5353
assert literal._data.mode == "w+"
5454
assert (tmp_path / "auto").is_file()
5555

56+
# load_data(memmap="auto") resolves the same sentinel as preload="auto"
5657
lazy = mne.io.read_raw_edf(source, preload=False, verbose="error")
57-
with chdir(tmp_path):
58+
with pytest.raises(ValueError, match="set_cache_dir"):
5859
lazy.load_data(memmap="auto")
59-
assert lazy._data.mode == "w+"
6060

6161

6262
@pytest.mark.parametrize(

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,6 @@ classifiers = [
115115
]
116116
dependencies = [
117117
"decorator >= 5.1",
118-
"filelock >= 3.18.0",
119118
"jinja2 >= 3.1",
120119
"lazy_loader >= 0.3",
121120
"matplotlib >= 3.9", # released 2024-05-15, will become 3.10 on 2026-12-14
@@ -171,6 +170,7 @@ full-no-qt = [
171170
"dipy >= 1.9", # released 2024-03-08, will become 1.10 on 2026-12-12
172171
"edfio >= 0.4.10",
173172
"eeglabio",
173+
"filelock >= 3.18.0",
174174
"h5py >= 2.4",
175175
"imageio >= 2.6.1",
176176
"imageio-ffmpeg >= 0.4.1",

tools/pylock.ci-old.toml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,6 @@ version = "2.2.1"
9393
sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", upload-time = 2025-09-01T09:48:10Z, size = 1129488, hashes = { sha256 = "3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4" } }
9494
wheels = [{ url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", upload-time = 2025-09-01T09:48:08Z, size = 28317, hashes = { sha256 = "760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017" } }]
9595

96-
[[packages]]
97-
name = "filelock"
98-
version = "3.18.0"
99-
sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", upload-time = 2025-03-14T07:11:40Z, size = 18075, hashes = { sha256 = "adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2" } }
100-
wheels = [{ url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", upload-time = 2025-03-14T07:11:39Z, size = 16215, hashes = { sha256 = "c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de" } }]
101-
10296
[[packages]]
10397
name = "fonttools"
10498
version = "4.61.1"

0 commit comments

Comments
 (0)