Skip to content

Commit a138fb7

Browse files
Simplify persistent Raw preload caching
1 parent b35640a commit a138fb7

9 files changed

Lines changed: 97 additions & 358 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
Speed up repeated preloading of uncompressed FIF, EDF/BDF, and BrainVision
1+
Speed up repeated preloading of FIF, EDF/BDF, and BrainVision
22
recordings with a persistent copy-on-write decoded-data cache, by `Bruno Aristimunha`_.

mne/io/_preload_cache.py

Lines changed: 52 additions & 191 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,8 @@
55
# Copyright the MNE-Python contributors.
66

77
import hashlib
8-
import json
98
import os
109
import pickle
11-
import stat
1210
from pathlib import Path
1311

1412
import numpy as np
@@ -20,20 +18,18 @@
2018
_RAW_PRELOAD_LOCK_TIMEOUT = 300.0
2119

2220

23-
def _raw_preload_open_regular(path):
24-
"""Open and validate a regular cache file."""
25-
file = open(path, "rb")
26-
try:
27-
if not stat.S_ISREG(os.fstat(file.fileno()).st_mode):
28-
raise OSError("Decoded data cache entries must be regular files")
29-
except Exception:
30-
file.close()
31-
raise
32-
return file
33-
21+
def _raw_preload_cache_info(raw):
22+
"""Return the cache path and decoded array description."""
23+
cache_root = get_config("MNE_CACHE_DIR", None)
24+
if cache_root is None:
25+
raise ValueError(
26+
'preload="auto" requires a configured cache directory; use '
27+
"mne.set_cache_dir(...) first"
28+
)
29+
cache_dir = Path(cache_root).expanduser().resolve()
30+
cache_dir = cache_dir / f"raw-preload-v{_RAW_PRELOAD_CACHE_VERSION}"
31+
cache_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
3432

35-
def _raw_preload_source_signature(raw):
36-
"""Return filesystem identities for the source data files."""
3733
sources = []
3834
for filename in raw.filenames:
3935
if filename is None:
@@ -42,206 +38,71 @@ def _raw_preload_source_signature(raw):
4238
"or an explicit memory-map path"
4339
)
4440
path = Path(filename).resolve(strict=True)
45-
if path.suffix == ".gz":
46-
raise ValueError(
47-
'preload="auto" supports only uncompressed source files; use '
48-
"preload=True for compressed files"
49-
)
5041
result = path.stat()
51-
if not stat.S_ISREG(result.st_mode):
52-
raise OSError("Raw source data must be regular files")
53-
# ponytail: hash contents only if path, size, and mtime prove insufficient.
5442
sources.append((str(path), int(result.st_size), int(result.st_mtime_ns)))
55-
return sources
56-
5743

58-
def _raw_preload_cache_dir(cache_root=None):
59-
"""Resolve and validate the managed cache directory."""
60-
if cache_root is None:
61-
cache_root = get_config("MNE_CACHE_DIR", None)
62-
if cache_root is None:
63-
raise ValueError(
64-
'preload="auto" requires a configured cache directory; use '
65-
"mne.set_cache_dir(...) first"
66-
)
67-
cache_root = Path(cache_root).expanduser().resolve()
68-
cache_dir = cache_root / f"raw-preload-v{_RAW_PRELOAD_CACHE_VERSION}"
69-
cache_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
70-
if cache_dir.is_symlink() or not cache_dir.is_dir():
71-
raise OSError(f"Decoded data cache must be a regular directory: {cache_dir}")
72-
return cache_dir
73-
74-
75-
def _raw_preload_cache_info(raw):
76-
"""Return the managed cache location and expected array description."""
77-
cache_dir = _raw_preload_cache_dir()
78-
sources = _raw_preload_source_signature(raw)
7944
dtype = np.dtype(raw._dtype)
8045
shape = (int(raw.info["nchan"]), int(raw.n_times))
81-
identity = dict(
82-
version=_RAW_PRELOAD_CACHE_VERSION,
83-
mne_version=MNE_VERSION,
84-
reader=(type(raw).__module__, type(raw).__qualname__),
85-
sources=sources,
86-
raw_extras=raw._raw_extras,
87-
read_picks=raw._read_picks,
88-
cals=raw._cals,
89-
projector=raw._projector,
90-
compensator=raw._comp,
91-
first_samps=raw._first_samps,
92-
last_samps=raw._last_samps,
93-
dtype=dtype.str,
94-
shape=shape,
46+
identity = (
47+
_RAW_PRELOAD_CACHE_VERSION,
48+
MNE_VERSION,
49+
type(raw).__module__,
50+
type(raw).__qualname__,
51+
sources,
52+
raw._raw_extras,
53+
raw._cals,
54+
dtype.str,
55+
shape,
9556
)
9657
try:
97-
serialized = pickle.dumps(identity, protocol=5)
58+
key = hashlib.sha256(pickle.dumps(identity, protocol=5)).hexdigest()
9859
except Exception as exc:
9960
raise ValueError(
10061
f'preload="auto" cannot identify this {type(raw).__name__} source'
10162
) from exc
102-
key = hashlib.sha256(serialized).hexdigest()
103-
return cache_dir, key, sources, shape, dtype
104-
105-
106-
def _raw_preload_generation_valid(name, key):
107-
"""Check that a manifest generation is a managed basename."""
108-
prefix = f"{key}."
109-
suffix = ".data"
110-
if (
111-
not isinstance(name, str)
112-
or not name.startswith(prefix)
113-
or not name.endswith(suffix)
114-
):
115-
return False
116-
token = name[len(prefix) : -len(suffix)]
117-
return len(token) == 32 and all(char in "0123456789abcdef" for char in token)
63+
return cache_dir / f"{key}.data", sources, shape, dtype
11864

11965

120-
def _raw_preload_read_manifest(cache_dir, key):
121-
"""Read one manifest through its validated handle."""
122-
path = cache_dir / f"{key}.json"
123-
with _raw_preload_open_regular(path) as file:
124-
if os.fstat(file.fileno()).st_size > 4096:
125-
raise ValueError("Oversized Raw preload manifest")
126-
return json.loads(file.read().decode("utf-8"))
127-
128-
129-
def _raw_preload_cache_read(raw, cache_dir, key, sources, shape, dtype):
130-
"""Read and validate one managed decoded-data cache entry."""
66+
def _raw_preload_cache_read(path, shape, dtype):
67+
"""Map a complete decoded-data cache entry."""
13168
try:
132-
manifest = _raw_preload_read_manifest(cache_dir, key)
133-
if set(manifest) != {"generation"}:
134-
return None
13569
nbytes = int(np.prod(shape, dtype=np.int64)) * dtype.itemsize
136-
if not _raw_preload_generation_valid(manifest["generation"], key):
70+
if path.stat().st_size != nbytes:
13771
return None
138-
generation = cache_dir / manifest["generation"]
139-
with _raw_preload_open_regular(generation) as file:
140-
if os.fstat(file.fileno()).st_size != nbytes:
141-
return None
142-
data = np.memmap(file, mode="c", dtype=dtype, shape=shape)
143-
data.filename = str(generation) # ty: ignore[invalid-assignment]
144-
if _raw_preload_source_signature(raw) != sources:
145-
data._mmap.close() # ty: ignore[unresolved-attribute] # memmap private
146-
return None
147-
except (OSError, TypeError, ValueError, json.JSONDecodeError):
72+
return np.memmap(path, mode="c", dtype=dtype, shape=shape)
73+
except OSError:
14874
return None
149-
logger.info(f"Reusing decoded data from {generation}")
150-
return data
151-
152-
153-
def _raw_preload_scavenge_key(cache_dir, key):
154-
"""Remove abandoned temporary and unreferenced same-key generations."""
155-
referenced = None
156-
try:
157-
manifest = _raw_preload_read_manifest(cache_dir, key)
158-
candidate = manifest.get("generation")
159-
if _raw_preload_generation_valid(candidate, key):
160-
referenced = candidate
161-
except (OSError, TypeError, ValueError, json.JSONDecodeError):
162-
pass
163-
patterns = (f".{key}.*.tmp", f"{key}.*.data")
164-
for pattern in patterns:
165-
for path in cache_dir.glob(pattern):
166-
if path.name == referenced:
167-
continue
168-
try:
169-
path.unlink()
170-
except OSError:
171-
logger.debug(
172-
f"Could not remove abandoned Raw preload cache file {path}"
173-
)
174-
175-
176-
def _raw_preload_cache_create(raw, cache_dir, key, sources, shape, dtype):
177-
"""Decode, durably publish, and reopen an immutable cache generation."""
178-
token = os.urandom(16).hex()
179-
generation_name = f"{key}.{token}.data"
180-
generation = cache_dir / generation_name
181-
temporary = cache_dir / f".{generation_name}.tmp"
182-
manifest_temporary = None
183-
manifest_published = False
184-
nbytes = int(np.prod(shape, dtype=np.int64)) * dtype.itemsize
185-
descriptor = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o600)
186-
try:
187-
with os.fdopen(descriptor, "r+b") as file:
188-
file.truncate(nbytes)
189-
data = np.memmap(file, mode="r+", dtype=dtype, shape=shape)
190-
try:
191-
raw._read_segment(data_buffer=data)
192-
data.flush()
193-
finally:
194-
data._mmap.close() # ty: ignore[unresolved-attribute] # memmap private
195-
os.fsync(file.fileno())
196-
if _raw_preload_source_signature(raw) != sources:
197-
raise RuntimeError(
198-
"Source data changed while decoded cache was created; retry"
199-
)
200-
os.replace(temporary, generation)
201-
manifest = dict(generation=generation_name)
202-
manifest_temporary = cache_dir / f".{key}.{os.urandom(16).hex()}.json.tmp"
203-
descriptor = os.open(
204-
manifest_temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600
205-
)
206-
with os.fdopen(descriptor, "w", encoding="utf-8") as file:
207-
json.dump(manifest, file, sort_keys=True, separators=(",", ":"))
208-
file.flush()
209-
os.fsync(file.fileno())
210-
os.replace(manifest_temporary, cache_dir / f"{key}.json")
211-
manifest_published = True
212-
with _raw_preload_open_regular(generation) as file:
213-
result = np.memmap(file, mode="c", dtype=dtype, shape=shape)
214-
result.filename = str(generation) # ty: ignore[invalid-assignment]
215-
return result
216-
finally:
217-
for path in (temporary, manifest_temporary):
218-
if path is not None:
219-
try:
220-
path.unlink(missing_ok=True)
221-
except OSError:
222-
pass
223-
if not manifest_published:
224-
try:
225-
generation.unlink(missing_ok=True)
226-
except OSError:
227-
pass
22875

22976

23077
def _raw_preload_auto(raw):
23178
"""Reuse or create an automatic decoded-data cache entry."""
232-
cache_dir, key, sources, shape, dtype = _raw_preload_cache_info(raw)
233-
key_lock = cache_dir / f"{key}.lock"
234-
data = _raw_preload_cache_read(raw, cache_dir, key, sources, shape, dtype)
79+
path, sources, shape, dtype = _raw_preload_cache_info(raw)
80+
data = _raw_preload_cache_read(path, shape, dtype)
23581
if data is not None:
82+
logger.info(f"Reusing decoded data from {path}")
23683
return data
84+
23785
# Importing filelock is measurable, so keep it off the cache-hit path.
23886
filelock = _soft_import("filelock", "locking the decoded-data cache")
239-
240-
with filelock.FileLock(key_lock, timeout=_RAW_PRELOAD_LOCK_TIMEOUT):
241-
_raw_preload_scavenge_key(cache_dir, key)
242-
data = _raw_preload_cache_read(raw, cache_dir, key, sources, shape, dtype)
87+
with filelock.FileLock(f"{path}.lock", timeout=_RAW_PRELOAD_LOCK_TIMEOUT):
88+
data = _raw_preload_cache_read(path, shape, dtype)
24389
if data is None:
244-
logger.info(f"Creating decoded data cache in {cache_dir}")
245-
data = _raw_preload_cache_create(raw, cache_dir, key, sources, shape, dtype)
246-
_raw_preload_scavenge_key(cache_dir, key)
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)
247108
return data

mne/io/base.py

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,10 @@ class BaseRaw(
145145
drive (slower, requires less memory). An existing file is overwritten.
146146
The caller owns the file and is responsible for removing it after the
147147
Raw object is no longer in use. For supported file readers, the exact
148-
string ``"auto"`` instead stores and reuses decoded data in the directory
149-
configured by :func:`mne.set_cache_dir`. Cached data persist in a
150-
versioned ``raw-preload`` directory and are mapped copy-on-write. Use
151-
``Path("auto")`` or ``"./auto"`` for a literal filename. If preload is
152-
an ndarray, the data are taken from that array. If False, data are not
153-
read until save.
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.
154152
first_samps : sequence
155153
Sequence of the first sample number from each raw file. For unsplit raw
156154
files this should be a length-one list or tuple.
@@ -600,22 +598,18 @@ def _check_bad_segment(
600598
def load_data(
601599
self,
602600
*,
603-
memmap: Path | Literal["auto"] | str | None = None,
601+
memmap: Path | str | None = None,
604602
verbose: bool | str | int | None = None,
605603
) -> Self:
606604
"""Load raw data.
607605
608606
Parameters
609607
----------
610-
memmap : path-like | "auto" | None
611-
If a path, preload data into a freshly created memory-mapped file at
612-
this path. An existing file is overwritten. The caller owns the file
613-
and is responsible for removing it after the Raw object is no longer
614-
in use. For supported file readers, ``"auto"`` instead reuses the
615-
persistent decoded-data cache configured by :func:`mne.set_cache_dir`.
616-
Cache entries for superseded source identities remain in
617-
a versioned ``raw-preload`` directory below the configured path.
618-
If ``None`` (default), preload data into RAM.
608+
memmap : path-like | None
609+
If not ``None``, preload data into a freshly created memory-mapped file
610+
at this path. An existing file is overwritten. The caller owns the file
611+
and is responsible for removing it after the Raw object is no longer in
612+
use. If ``None`` (default), preload data into RAM.
619613
620614
.. versionadded:: 1.13
621615
%(verbose)s
@@ -635,6 +629,7 @@ def load_data(
635629
if not self.preload:
636630
if memmap is not None:
637631
_validate_type(memmap, "path-like", "memmap")
632+
memmap = Path(memmap)
638633
self._preload_data(memmap if memmap is not None else True)
639634
return self
640635

@@ -650,12 +645,9 @@ def _preload_data(self, preload):
650645
data_buffer = preload
651646
if isinstance(preload, bool | np.bool_) and not preload:
652647
data_buffer = None
653-
# Avoid materializing ``self.times``; that scales with the recording
654-
# length and can dominate a decoded-cache hit.
655-
n_times = self.n_times
656-
last_time = (n_times - 1) / self.info["sfreq"]
648+
t = self.times
657649
logger.info(
658-
f"Reading 0 ... {n_times - 1} = {0.0:9.3f} ... {last_time:9.3f} secs..."
650+
f"Reading 0 ... {len(t) - 1} = {0.0:9.3f} ... {t[-1]:9.3f} secs..."
659651
)
660652
self._data = self._read_segment(data_buffer=data_buffer)
661653
assert len(self._data) == self.info["nchan"]

mne/io/fiff/raw.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,6 @@ def __init__(
104104
'preload="auto" requires stable source files and is not '
105105
"supported for file-like FIF inputs"
106106
)
107-
if isinstance(fname, Path | str) and Path(fname).suffix == ".gz":
108-
raise ValueError(
109-
'preload="auto" supports only uncompressed FIF files; use '
110-
"preload=True for gzip-compressed FIF"
111-
)
112107
raws = []
113108
do_check_ext = not _file_like(fname)
114109
next_fname = fname
@@ -209,7 +204,9 @@ def _read_raw_file(
209204
check_fname(fname, "raw", endings)
210205
# filename
211206
fname = _check_fname(fname, "read", True, "fname")
212-
whole_file = preload if fname.suffix == ".gz" else False
207+
whole_file = (
208+
preload if preload != "auto" and fname.suffix == ".gz" else False
209+
)
213210
else:
214211
# file-like
215212
if not preload:

0 commit comments

Comments
 (0)