Skip to content

Commit 9fbdbf9

Browse files
Trim the PR: drop the byte budget, threading and test bloat
Four review passes (reuse, simplification, efficiency, altitude) against the shape of #14216. Measured rather than assumed: - byte-budget heuristic: never fires. n_per already caps a chunk near 10 MiB of source bytes, so temporaries stay under 50 MB of the 64 MB cap even for an adversarial 2-channel/6-hour file with reversed picks. - n_read == 1 branch: 0/200 hits on the windowed benchmark, and the general branch produces the same values. - threading: real (13-22% on preload) but reachable only via direct_output, and it is one of only two ThreadPoolExecutor sites in MNE. mne.parallel already has parallel_func(prefer='threads'). Deferred to its own PR with a benchmark. - direct_output: kept, it measures 27-28% on full preload. _read_segments_file mmap/threading and the BrainVision block sizing are reverted to main and move to a follow-up: no MNE fixture is large enough to reach the 64 MB threading threshold (largest .eeg is 3.7 MB), so it was untested at any realistic scale. Tests 549 -> 156 lines: four near-identical stride tests merged into one parametrized test, redundant file_kind axes dropped, and a test asserting x * 1.0 == x bit-exactly removed. +884/-29 -> +330/-18. Output stays bit-identical to main: 77/77 array snapshots and 56/56 annotation snapshots across 28 files.
1 parent 990b264 commit 9fbdbf9

10 files changed

Lines changed: 631 additions & 663 deletions

File tree

mne/_fiff/tests/test_utils.py

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

7-
import threading
8-
from types import SimpleNamespace
9-
10-
import numpy as np
11-
import pytest
12-
from numpy.testing import assert_allclose, assert_array_equal
13-
14-
from mne._fiff import utils as fiff_utils
15-
from mne._fiff.utils import _check_orig_units, _read_segments_file
7+
from mne._fiff.utils import _check_orig_units
168

179

1810
def test_check_orig_units():
@@ -24,150 +16,3 @@ def test_check_orig_units():
2416
assert orig_units["Pz"] == "µV"
2517
assert orig_units["greekMu"] == "µV"
2618
assert orig_units["microSign"] == "µV"
27-
28-
29-
@pytest.mark.parametrize("use_mult", (False, True))
30-
def test_read_segments_file_max_block_bytes(tmp_path, use_mult):
31-
"""Test reading in configurable complete channel frames."""
32-
source = np.arange(20, dtype="<i2").reshape(2, 10, order="F")
33-
data_fname = tmp_path / "interleaved.bin"
34-
source.ravel(order="F").tofile(data_fname)
35-
raw = SimpleNamespace(
36-
filenames=[data_fname], _raw_extras=[dict(orig_nchan=source.shape[0])]
37-
)
38-
if use_mult:
39-
data = np.empty((1, 7))
40-
cals = None
41-
mult = np.array([[0.5, -2.0]])
42-
want = mult @ source[:, 1:8]
43-
else:
44-
data = np.empty((2, 7))
45-
cals = np.array([0.5, -2.0])
46-
mult = None
47-
want = source[:, 1:8] * cals[:, np.newaxis]
48-
49-
_read_segments_file(
50-
raw,
51-
data,
52-
slice(None),
53-
0,
54-
1,
55-
8,
56-
cals,
57-
mult,
58-
dtype=source.dtype,
59-
max_block_bytes=1,
60-
)
61-
62-
if use_mult:
63-
assert_allclose(data, want, rtol=1e-15)
64-
else:
65-
assert_array_equal(data, want)
66-
67-
68-
def test_read_segments_file_mmap_threaded(tmp_path, monkeypatch):
69-
"""Test mapped blocks are calibrated on worker threads."""
70-
source = np.arange(40, dtype="<i2").reshape(2, 20, order="F")
71-
data_fname = tmp_path / "interleaved.bin"
72-
source.ravel(order="F").tofile(data_fname)
73-
raw = SimpleNamespace(filenames=[data_fname], _raw_extras=[dict(orig_nchan=2)])
74-
data = np.empty(source.shape)
75-
thread_ids = set()
76-
calibrate = fiff_utils._mult_cal_one
77-
78-
def _record_thread(*args):
79-
thread_ids.add(threading.get_ident())
80-
return calibrate(*args)
81-
82-
def _fail_fromfile(*args, **kwargs):
83-
raise AssertionError("mapped reads must not use np.fromfile")
84-
85-
monkeypatch.setattr(fiff_utils, "_READ_SEGMENTS_FILE_THREAD_MIN_BYTES", 0)
86-
monkeypatch.setattr(fiff_utils, "_mult_cal_one", _record_thread)
87-
monkeypatch.setattr(np, "fromfile", _fail_fromfile)
88-
_read_segments_file(
89-
raw,
90-
data,
91-
slice(None),
92-
0,
93-
0,
94-
source.shape[1],
95-
np.ones(source.shape[0]),
96-
None,
97-
dtype=source.dtype,
98-
max_block_bytes=8,
99-
use_mmap=True,
100-
n_jobs=2,
101-
)
102-
103-
assert threading.get_ident() not in thread_ids
104-
assert_array_equal(data, source)
105-
106-
107-
def test_read_segments_file_mmap_fallback(tmp_path, monkeypatch):
108-
"""Test a failed source mapping falls back to ordinary file reads."""
109-
source = np.arange(20, dtype="<i2").reshape(2, 10, order="F")
110-
data_fname = tmp_path / "interleaved.bin"
111-
source.ravel(order="F").tofile(data_fname)
112-
raw = SimpleNamespace(filenames=[data_fname], _raw_extras=[dict(orig_nchan=2)])
113-
data = np.empty(source.shape)
114-
115-
def _fail_mmap(*args, **kwargs):
116-
raise OSError("mapping unavailable")
117-
118-
monkeypatch.setattr(fiff_utils.mmap, "mmap", _fail_mmap)
119-
_read_segments_file(
120-
raw,
121-
data,
122-
slice(None),
123-
0,
124-
0,
125-
source.shape[1],
126-
np.ones(source.shape[0]),
127-
None,
128-
dtype=source.dtype,
129-
max_block_bytes=1,
130-
use_mmap=True,
131-
n_jobs=2,
132-
)
133-
134-
assert_array_equal(data, source)
135-
136-
137-
def test_read_segments_file_mmap_worker_error(tmp_path, monkeypatch):
138-
"""Test worker errors do not prevent closing the source mapping."""
139-
source = np.arange(20, dtype="<i2").reshape(2, 10, order="F")
140-
data_fname = tmp_path / "interleaved.bin"
141-
source.ravel(order="F").tofile(data_fname)
142-
raw = SimpleNamespace(filenames=[data_fname], _raw_extras=[dict(orig_nchan=2)])
143-
mappings = []
144-
map_file = fiff_utils.mmap.mmap
145-
146-
def _record_mapping(*args, **kwargs):
147-
mapping = map_file(*args, **kwargs)
148-
mappings.append(mapping)
149-
return mapping
150-
151-
def _fail_calibration(*args):
152-
raise RuntimeError("expected worker failure")
153-
154-
monkeypatch.setattr(fiff_utils, "_READ_SEGMENTS_FILE_THREAD_MIN_BYTES", 0)
155-
monkeypatch.setattr(fiff_utils, "_mult_cal_one", _fail_calibration)
156-
monkeypatch.setattr(fiff_utils.mmap, "mmap", _record_mapping)
157-
with pytest.raises(RuntimeError, match="expected worker failure"):
158-
_read_segments_file(
159-
raw,
160-
np.empty(source.shape),
161-
slice(None),
162-
0,
163-
0,
164-
source.shape[1],
165-
np.ones(source.shape[0]),
166-
None,
167-
dtype=source.dtype,
168-
max_block_bytes=8,
169-
use_mmap=True,
170-
n_jobs=2,
171-
)
172-
173-
assert mappings[0].closed

mne/_fiff/utils.py

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

5-
import mmap
65
import os
76
import os.path as op
8-
from concurrent.futures import ThreadPoolExecutor
97
from pathlib import Path
10-
from traceback import clear_frames
118

129
import numpy as np
1310

1411
from .constants import FIFF
1512
from .meas_info import _get_valid_units
1613

17-
_READ_SEGMENTS_FILE_THREAD_MIN_BYTES = 64 * 1024**2
18-
1914

2015
def _check_orig_units(orig_units):
2116
"""Check original units from a raw file.
@@ -219,9 +214,6 @@ def _read_segments_file(
219214
n_channels=None,
220215
offset=0,
221216
trigger_ch=None,
222-
max_block_bytes=int(100e6),
223-
use_mmap=False,
224-
n_jobs=1,
225217
):
226218
"""Read a chunk of raw data."""
227219
if n_channels is None:
@@ -233,90 +225,28 @@ def _read_segments_file(
233225
data_offset = n_channels * start * n_bytes + offset
234226
data_left = (stop - start) * n_channels
235227

236-
# block_size is in data samples and spans complete channel frames.
237-
block_size = max(
238-
n_channels,
239-
((max_block_bytes // n_bytes) // n_channels) * n_channels,
240-
)
228+
# Read up to 100 MB of data at a time, block_size is in data samples
229+
block_size = ((int(100e6) // n_bytes) // n_channels) * n_channels
241230
block_size = min(data_left, block_size)
242231
with open(raw.filenames[fi], "rb", buffering=0) as fid:
243-
mapped = None
244-
if use_mmap and data_left * n_bytes >= max_block_bytes:
245-
try:
246-
mapped = mmap.mmap(fid.fileno(), 0, access=mmap.ACCESS_READ)
247-
except (OSError, ValueError):
248-
pass
249232
fid.seek(data_offset)
250233
# extract data in chunks
251-
executor = None
252-
pending = []
253-
worker_error = None
254-
if (
255-
mapped is not None
256-
and n_jobs > 1
257-
and data.nbytes >= _READ_SEGMENTS_FILE_THREAD_MIN_BYTES
258-
):
259-
executor = ThreadPoolExecutor(max_workers=n_jobs)
260-
try:
261-
for sample_start in np.arange(0, data_left, block_size) // n_channels:
262-
count = min(block_size, data_left - sample_start * n_channels)
263-
raw_block = None
264-
block = None
265-
try:
266-
if mapped is None:
267-
raw_block = np.fromfile(fid, dtype, count)
268-
else:
269-
byte_offset = data_offset + sample_start * n_channels * n_bytes
270-
if len(mapped) - byte_offset < count * n_bytes:
271-
raw_block = np.empty(0, dtype=dtype)
272-
else:
273-
raw_block = np.frombuffer(
274-
mapped, dtype, count=count, offset=byte_offset
275-
)
276-
if raw_block.size != count:
277-
raise RuntimeError(
278-
f"Incorrect number of samples ({raw_block.size} != "
279-
f"{count}), please report this error to MNE-Python "
280-
"developers"
281-
)
282-
block = raw_block.reshape(n_channels, -1, order="F")
283-
n_samples = block.shape[1] # = count // n_channels
284-
sample_stop = sample_start + n_samples
285-
if trigger_ch is not None:
286-
stim_ch = trigger_ch[start:stop][sample_start:sample_stop]
287-
block = np.vstack((block, stim_ch))
288-
data_view = data[:, sample_start:sample_stop]
289-
if executor is None:
290-
_mult_cal_one(data_view, block, idx, cals, mult)
291-
else:
292-
future = executor.submit(
293-
_mult_cal_one, data_view, block, idx, cals, mult
294-
)
295-
pending.append((future, block, raw_block))
296-
finally:
297-
del block, raw_block
298-
for pending_item in pending:
299-
error = pending_item[0].exception()
300-
if worker_error is None and error is not None:
301-
worker_error = error
302-
if pending:
303-
del pending_item
304-
finally:
305-
if executor is not None:
306-
executor.shutdown(wait=True)
307-
# Worker tracebacks retain their mapped NumPy inputs. Clear frame locals
308-
# before closing the mapping, but keep the traceback locations intact.
309-
for pending_item in pending:
310-
error = pending_item[0].exception()
311-
if error is not None and error.__traceback__ is not None:
312-
clear_frames(error.__traceback__)
313-
if pending:
314-
del pending_item
315-
pending.clear()
316-
if mapped is not None:
317-
mapped.close()
318-
if worker_error is not None:
319-
raise worker_error
234+
for sample_start in np.arange(0, data_left, block_size) // n_channels:
235+
count = min(block_size, data_left - sample_start * n_channels)
236+
block = np.fromfile(fid, dtype, count)
237+
if block.size != count:
238+
raise RuntimeError(
239+
f"Incorrect number of samples ({block.size} != {count}), please "
240+
"report this error to MNE-Python developers"
241+
)
242+
block = block.reshape(n_channels, -1, order="F")
243+
n_samples = block.shape[1] # = count // n_channels
244+
sample_stop = sample_start + n_samples
245+
if trigger_ch is not None:
246+
stim_ch = trigger_ch[start:stop][sample_start:sample_stop]
247+
block = np.vstack((block, stim_ch))
248+
data_view = data[:, sample_start:sample_stop]
249+
_mult_cal_one(data_view, block, idx, cals, mult)
320250

321251

322252
def read_str(fid, count=1):

mne/io/brainvision/brainvision.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,6 @@
3535
)
3636
from ..base import BaseRaw
3737

38-
_BRAINVISION_BLOCK_BYTES = 8 * 1024**2
39-
_BRAINVISION_READ_WORKERS = 4
40-
4138

4239
@fill_doc
4340
class RawBrainVision(BaseRaw):
@@ -194,9 +191,6 @@ def _read_segment_file(self, data, idx, fi, start, stop, cals, mult):
194191
mult,
195192
dtype=dtype,
196193
n_channels=n_data_ch,
197-
max_block_bytes=_BRAINVISION_BLOCK_BYTES,
198-
use_mmap=True,
199-
n_jobs=_BRAINVISION_READ_WORKERS,
200194
)
201195
else:
202196
offsets = self._raw_extras[fi]["offsets"]

0 commit comments

Comments
 (0)