Skip to content

Commit 1dc4c8b

Browse files
Preserve caller-owned preload files
1 parent fb27de9 commit 1dc4c8b

7 files changed

Lines changed: 115 additions & 41 deletions

File tree

doc/changes/dev/14213.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Preserve caller-owned memory-mapped preload files after Raw objects are destroyed, by `Bruno Aristimunha`_.

mne/io/array/_array.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ class RawArray(BaseRaw):
3131
Determines what gets copied on instantiation. "auto" (default)
3232
will copy info, and copy "data" only if necessary to get to
3333
double floating point precision.
34+
If ``data`` is a memory-mapped array and is not copied, the caller
35+
retains ownership of its backing file and is responsible for removing
36+
it after the Raw object is no longer in use.
3437
3538
.. versionadded:: 0.18
3639
%(verbose)s

mne/io/base.py

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
# License: BSD-3-Clause
33
# Copyright the MNE-Python contributors.
44

5-
import os
65
import shutil
76
from collections import defaultdict
87
from collections.abc import Callable, Sequence
@@ -140,11 +139,12 @@ class BaseRaw(
140139
preload : bool | str | ndarray
141140
Preload data into memory for data manipulation and faster indexing.
142141
If True, the data will be preloaded into memory (fast, requires
143-
large amount of memory). If preload is a string, preload is the
144-
file name of a memory-mapped file which is used to store the data
145-
on the hard drive (slower, requires less memory). If preload is an
146-
ndarray, the data are taken from that array. If False, data are not
147-
read until save.
142+
large amount of memory). If preload is a string, it is the name of a
143+
freshly created memory-mapped file used to store the data on the hard
144+
drive (slower, requires less memory). An existing file is overwritten.
145+
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.
148148
first_samps : sequence
149149
Sequence of the first sample number from each raw file. For unsplit raw
150150
files this should be a length-one list or tuple.
@@ -602,8 +602,10 @@ def load_data(
602602
Parameters
603603
----------
604604
memmap : path-like | None
605-
If not ``None``, preload data into a memory-mapped file at this
606-
path. If ``None`` (default), preload data into RAM.
605+
If not ``None``, preload data into a freshly created memory-mapped file
606+
at this path. An existing file is overwritten. The caller owns the file
607+
and is responsible for removing it after the Raw object is no longer in
608+
use. If ``None`` (default), preload data into RAM.
607609
608610
.. versionadded:: 1.13
609611
%(verbose)s
@@ -812,18 +814,6 @@ def set_annotations(
812814

813815
return self
814816

815-
def __del__(self): # noqa: D105
816-
# remove file for memmap
817-
fname = getattr(getattr(self, "_data", None), "filename", None)
818-
if fname is not None:
819-
# First, close the file out; happens automatically on del
820-
del self._data
821-
# Now file can be removed
822-
try:
823-
os.remove(fname)
824-
except OSError:
825-
pass # ignore file that no longer exists
826-
827817
def __enter__(self):
828818
"""Entering with block."""
829819
return self

mne/io/edf/tests/test_edf.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Copyright the MNE-Python contributors.
44

55
import datetime
6+
import gc
67
from contextlib import nullcontext
78
from functools import partial
89
from io import BytesIO
@@ -317,6 +318,28 @@ def test_edf_data_broken(tmp_path):
317318
assert_allclose(data, data_new)
318319

319320

321+
@pytest.mark.parametrize("method", ("constructor", "load_data"))
322+
def test_edf_preload_memmap_ownership(method, tmp_path):
323+
"""Test ownership of a populated EDF preload memmap."""
324+
memmap_fname = tmp_path / f"edf-{method}-memmap.dat"
325+
if method == "constructor":
326+
raw = read_raw_edf(edf_stim_channel_path, preload=memmap_fname)
327+
else:
328+
raw = read_raw_edf(edf_stim_channel_path, preload=False)
329+
raw.load_data(memmap=memmap_fname)
330+
shape = raw._data.shape
331+
expected = raw.get_data(picks=[0], start=0, stop=10)[0]
332+
assert expected.any()
333+
assert memmap_fname.stat().st_size == raw._data.nbytes
334+
del raw
335+
gc.collect()
336+
337+
assert memmap_fname.is_file()
338+
data = np.memmap(memmap_fname, dtype=np.float64, mode="r", shape=shape)
339+
assert_array_equal(data[0, :10], expected)
340+
del data
341+
342+
320343
def test_duplicate_channel_labels_edf():
321344
"""Test reading edf file with duplicate channel names."""
322345
EXPECTED_CHANNEL_NAMES = ["EEG F1-Ref-0", "EEG F2-Ref", "EEG F1-Ref-1"]

mne/io/fiff/tests/test_raw_fiff.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Copyright the MNE-Python contributors.
44

55
import datetime
6+
import gc
67
import os
78
import pathlib
89
import pickle
@@ -315,7 +316,8 @@ def test_multiple_files(tmp_path):
315316
)
316317
_compare_combo(raw, raw_combo, times, n_times)
317318
raw_combo = concatenate_raws(
318-
[read_raw_fif(f) for f in [fif_fname, fif_fname]], preload="memmap8.dat"
319+
[read_raw_fif(f) for f in [fif_fname, fif_fname]],
320+
preload=tmp_path / "memmap8.dat",
319321
)
320322
_compare_combo(raw, raw_combo, times, n_times)
321323
assert raw[:, :][0].shape[1] * 2 == raw_combo0[:, :][0].shape[1]
@@ -349,13 +351,13 @@ def test_multiple_files(tmp_path):
349351

350352
raw_combo = concatenate_raws(
351353
[read_raw_fif(fif_fname, preload=False), read_raw_fif(fif_fname, preload=True)],
352-
preload="memmap3.dat",
354+
preload=tmp_path / "memmap3.dat",
353355
)
354356
_compare_combo(raw, raw_combo, times, n_times)
355357

356358
raw_combo = concatenate_raws(
357359
[read_raw_fif(fif_fname, preload=True), read_raw_fif(fif_fname, preload=True)],
358-
preload="memmap4.dat",
360+
preload=tmp_path / "memmap4.dat",
359361
)
360362
_compare_combo(raw, raw_combo, times, n_times)
361363

@@ -364,7 +366,7 @@ def test_multiple_files(tmp_path):
364366
read_raw_fif(fif_fname, preload=False),
365367
read_raw_fif(fif_fname, preload=False),
366368
],
367-
preload="memmap5.dat",
369+
preload=tmp_path / "memmap5.dat",
368370
)
369371
_compare_combo(raw, raw_combo, times, n_times)
370372

@@ -957,9 +959,9 @@ def test_io_complex(tmp_path, dtype):
957959

958960

959961
@testing.requires_testing_data
960-
def test_getitem():
962+
def test_getitem(tmp_path):
961963
"""Test getitem/indexing of Raw."""
962-
for preload in [False, True, "memmap1.dat"]:
964+
for preload in [False, True, tmp_path / "memmap1.dat"]:
963965
raw = read_raw_fif(fif_fname, preload=preload)
964966
data, times = raw[0, :]
965967
data1, times1 = raw[0]
@@ -1078,9 +1080,11 @@ def test_proj(tmp_path):
10781080

10791081

10801082
@testing.requires_testing_data
1081-
@pytest.mark.parametrize("preload", [False, True, "memmap2.dat"])
1083+
@pytest.mark.parametrize("preload", [False, True, "memmap"])
10821084
def test_preload_modify(preload, tmp_path):
10831085
"""Test preloading and modifying data."""
1086+
if preload == "memmap":
1087+
preload = tmp_path / "memmap2.dat"
10841088
rng = np.random.default_rng(0)
10851089
raw = read_raw_fif(fif_fname, preload=preload)
10861090

@@ -2030,6 +2034,9 @@ def test_memmap(tmp_path):
20302034
raw_0._data[:] = 0.0
20312035
assert not raw_0._data.any()
20322036
assert raw_1._data[:1, 3:5].all()
2037+
del raw_0, raw_1
2038+
gc.collect()
2039+
assert Path(memmaps[3]).is_file()
20332040
# other things like drop_channels and crop work but do not use memmapping,
20342041
# eventually we might want to add support for some of these as users
20352042
# require them.

mne/io/tests/test_raw.py

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

7+
import gc
78
import math
89
import re
910
from contextlib import chdir, redirect_stdout
@@ -838,16 +839,62 @@ def _read_raw_arange(preload=False, verbose=None):
838839
return _RawArange(preload, verbose)
839840

840841

841-
def test_load_data_memmap(tmp_path):
842-
"""Test loading raw data into a memmap via load_data."""
843-
raw = _read_raw_arange(preload=False)
844-
memmap_fname = tmp_path / "raw-load-data-memmap.dat"
845-
raw.load_data(memmap=memmap_fname)
842+
@pytest.mark.parametrize("method", ("constructor", "load_data"))
843+
def test_preload_memmap_ownership(method, tmp_path):
844+
"""Test that a caller owns an explicit preload memmap path."""
845+
memmap_fname = tmp_path / f"raw-{method}-memmap.dat"
846+
memmap_fname.write_bytes(b"stale" * 20_000)
847+
if method == "constructor":
848+
raw = _read_raw_arange(preload=memmap_fname)
849+
else:
850+
raw = _read_raw_arange(preload=False)
851+
raw.load_data(memmap=memmap_fname)
846852

847853
assert raw.preload
848854
assert isinstance(raw._data, np.memmap)
849855
assert Path(raw._data.filename) == memmap_fname
850-
assert_array_equal(raw._data[:, 0], np.arange(1, 9))
856+
assert memmap_fname.stat().st_size == raw._data.nbytes
857+
assert_array_equal(raw.get_data()[:, 0], np.arange(1, 9))
858+
raw.close()
859+
assert_array_equal(raw.get_data()[:, 0], np.arange(1, 9))
860+
del raw
861+
gc.collect()
862+
863+
assert memmap_fname.is_file()
864+
data = np.memmap(memmap_fname, dtype=np.float64, mode="r", shape=(8, 1000))
865+
assert_array_equal(data[:, 0], np.arange(1, 9))
866+
del data
867+
replacement = tmp_path / "replacement.dat"
868+
replacement.write_bytes(b"replacement")
869+
replacement.replace(memmap_fname)
870+
assert memmap_fname.read_bytes() == b"replacement"
871+
872+
873+
def test_append_memmap_ownership(tmp_path):
874+
"""Test ownership of a caller-named concatenation memmap."""
875+
memmap_fname = tmp_path / "raw-append-memmap.dat"
876+
raw = _read_raw_arange()
877+
other = _read_raw_arange()
878+
raw.append(other, preload=memmap_fname)
879+
assert isinstance(raw._data, np.memmap)
880+
expected = np.repeat(np.arange(1, 9)[:, np.newaxis], 2, axis=1)
881+
assert_array_equal(raw.get_data()[:, [0, -1]], expected)
882+
copied = raw.copy()
883+
assert_array_equal(copied.get_data(), raw.get_data())
884+
del copied, other, raw
885+
gc.collect()
886+
assert memmap_fname.is_file()
887+
888+
889+
def test_raw_array_memmap_ownership(tmp_path):
890+
"""Test ownership of a memmap supplied directly to RawArray."""
891+
memmap_fname = tmp_path / "raw-array-memmap.dat"
892+
source = np.memmap(memmap_fname, dtype=np.float64, mode="w+", shape=(2, 10))
893+
source[:] = np.arange(20).reshape(2, 10)
894+
raw = RawArray(source, create_info(2, 10.0), copy=None)
895+
del source, raw
896+
gc.collect()
897+
assert memmap_fname.is_file()
851898

852899

853900
def test_test_raw_reader():

mne/utils/docs.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3697,19 +3697,22 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):
36973697
preload : bool | str
36983698
Preload data into memory for data manipulation and faster indexing.
36993699
If True, the data will be preloaded into memory (fast, requires
3700-
large amount of memory). If preload is a string, preload is the
3701-
file name of a memory-mapped file which is used to store the data
3702-
on the hard drive (slower, requires less memory)."""
3700+
large amount of memory). If preload is a string, it is the name of a
3701+
freshly created memory-mapped file used to store the data on the hard
3702+
drive (slower, requires less memory). An existing file is overwritten.
3703+
The caller owns the file and is responsible for removing it after the
3704+
Raw object is no longer in use."""
37033705

37043706
docdict["preload_concatenate"] = """
37053707
preload : bool | str | None
37063708
Preload data into memory for data manipulation and faster indexing.
37073709
If True, the data will be preloaded into memory (fast, requires
3708-
large amount of memory). If preload is a string, preload is the
3709-
file name of a memory-mapped file which is used to store the data
3710-
on the hard drive (slower, requires less memory). If preload is
3711-
None, preload=True or False is inferred using the preload status
3712-
of the instances passed in.
3710+
large amount of memory). If preload is a string, it is the name of a
3711+
freshly created memory-mapped file used to store the data on the hard
3712+
drive (slower, requires less memory). An existing file is overwritten.
3713+
The caller owns the file and is responsible for removing it after the
3714+
Raw object is no longer in use. If preload is None, preload=True or False
3715+
is inferred using the preload status of the instances passed in.
37133716
"""
37143717

37153718
docdict["proj_epochs"] = """

0 commit comments

Comments
 (0)