Skip to content

Commit 0f6c144

Browse files
ratalclaude
andcommitted
perf: 4x speedup for large MDF4 files, Cython SymBufReader, bump to v4.3
- read_all_channels_sorted_record: replace chunk loop with single readinto() into a pre-allocated recarray (zero-copy, writeable) — the main win: T3 1.7 GB file drops from 1.5s to 0.4s (4x faster) - DZ transpose: arr.T.tobytes() instead of .copy() + tobytes() (one fewer intermediate allocation) - Vectorize sign-extension in _apply_unsorted_bit_masking using np.where(negative, bitwise_or(temp_u, sign_extend), temp_u) - SI block cache in Info4._si_cache (keyed by file pointer) to skip duplicate Source Information block reads - Add SymBufReader cdef class to dataRead.pyx: bidirectional-buffered file wrapper that fills its C-level buffer centred on the current position, matching the mdfr Rust SymBufReader design; activated for all Info4 metadata reads via _SymBufReader import - Bump version to 4.3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a04b1c8 commit 0f6c144

8 files changed

Lines changed: 13581 additions & 8065 deletions

File tree

dataRead.c

Lines changed: 13454 additions & 8032 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
408 KB
Binary file not shown.

dataRead.pyx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,95 @@ from cpython.bytes cimport PyBytes_AsString
99
from libc.string cimport memcpy
1010
cimport cython
1111

12+
# ──────────────────────────────────────────────────────────────────────────────
13+
# SymBufReader — bidirectional-buffered file reader
14+
#
15+
# MDF4 metadata blocks are linked by absolute file pointers that often point
16+
# *backward* in the file (blocks are written newest-first). Python's default
17+
# BufferedReader fills its buffer forward, so every backward seek past the
18+
# buffer start forces a new read syscall. This class mirrors the Rust mdfr
19+
# SymBufReader: it keeps a fixed C-level buffer filled *centred* on the current
20+
# position, so backward seeks within BUFSIZE/2 are served from cache.
21+
# ──────────────────────────────────────────────────────────────────────────────
22+
23+
DEF SYM_BUF_SIZE = 65536 # 64 KB — same default as Rust SymBufReader
24+
25+
cdef class SymBufReader:
26+
"""Bidirectional-buffered wrapper around a Python file object.
27+
28+
Drop-in replacement for any ``fid`` used in mdfreader metadata parsing:
29+
supports ``seek(pos[, whence])``, ``read([n])``, ``tell()``, ``fileno()``.
30+
"""
31+
cdef object _fid # underlying Python file
32+
cdef unsigned char _buf[SYM_BUF_SIZE] # C-level buffer (no GC pressure)
33+
cdef Py_ssize_t _buf_start # file offset of _buf[0]
34+
cdef Py_ssize_t _buf_len # valid bytes currently in _buf
35+
cdef Py_ssize_t _pos # logical file position (cursor)
36+
37+
def __init__(self, fid):
38+
self._fid = fid
39+
self._buf_start = 0
40+
self._buf_len = 0
41+
self._pos = 0
42+
43+
cdef int _fill(self, Py_ssize_t pos) except -1:
44+
"""Refill _buf centred on pos, reading from the underlying file."""
45+
cdef Py_ssize_t start = pos - (SYM_BUF_SIZE >> 1)
46+
cdef bytes raw
47+
cdef Py_ssize_t n
48+
if start < 0:
49+
start = 0
50+
self._fid.seek(start)
51+
raw = self._fid.read(SYM_BUF_SIZE)
52+
n = len(raw)
53+
memcpy(self._buf, <const unsigned char*>raw, n)
54+
self._buf_start = start
55+
self._buf_len = n
56+
return 0
57+
58+
def seek(self, Py_ssize_t pos, int whence=0):
59+
"""Update logical cursor; does NOT touch the underlying file."""
60+
if whence == 0:
61+
self._pos = pos
62+
elif whence == 1:
63+
self._pos += pos
64+
else: # whence == 2 (from end)
65+
self._fid.seek(0, 2)
66+
self._pos = self._fid.tell() + pos
67+
return self._pos
68+
69+
def tell(self):
70+
return self._pos
71+
72+
def read(self, Py_ssize_t n=-1):
73+
"""Return up to n bytes from the current position (or all if n<0)."""
74+
cdef Py_ssize_t pos = self._pos
75+
cdef Py_ssize_t buf_end, offset, available, end
76+
buf_end = self._buf_start + self._buf_len
77+
# Fast path: serve directly from buffer
78+
if self._buf_len > 0 and self._buf_start <= pos < buf_end:
79+
offset = pos - self._buf_start
80+
available = self._buf_len - offset
81+
if n < 0 or available >= n:
82+
end = offset + (n if n >= 0 else available)
83+
data = bytes(self._buf[offset:end])
84+
self._pos += len(data)
85+
return data
86+
# Buffer miss: refill centred on pos, then serve
87+
self._fill(pos)
88+
offset = pos - self._buf_start
89+
if offset >= self._buf_len:
90+
return b''
91+
available = self._buf_len - offset
92+
end = offset + (n if n >= 0 else available)
93+
data = bytes(self._buf[offset:end])
94+
self._pos += len(data)
95+
return data
96+
97+
def fileno(self):
98+
return self._fid.fileno()
99+
100+
12101
@cython.boundscheck(False)
13102
@cython.wraparound(False)
14103
def sorted_data_read(bytes tmp, unsigned short bit_count,

mdfconverter/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@
1212
# along with this program. If not, see http://www.gnu.org/licenses.
1313
#
1414
# ----------------------------------------------------------------------
15-
__version__ = "4.2"
15+
__version__ = "4.3"

mdfreader/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
__author__ = 'Aymeric Rateau (aymeric.rateau@gmail.com)'
1717
__copyright__ = 'Copyright (c) 2017 Aymeric Rateau'
1818
__license__ = 'GPLV3'
19-
__version__ = "4.2"
19+
__version__ = "4.3"
2020

2121
from .mdfreader import Mdf, MdfInfo
2222

mdfreader/mdf4reader.py

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
else:
3434
from numpy.core.records import fromstring, fromarrays
3535
from numpy import array, recarray, asarray, empty, where, frombuffer, reshape
36-
from numpy import arange, right_shift, bitwise_and, all, diff, interp, zeros, concatenate, maximum
36+
from numpy import arange, right_shift, bitwise_and, bitwise_or, all, diff, interp, zeros, concatenate, maximum
3737
from numpy import issubdtype, number as numpy_number
3838
from numpy import max as npmax, min as npmin
3939
from numpy.lib.recfunctions import rename_fields
@@ -181,11 +181,9 @@ def _apply_unsorted_bit_masking(buf, record, info):
181181
if sig_dt in (2, 3): # signed: sign-extend from bit_count-1
182182
sign_bit_mask = 1 << (bit_count - 1)
183183
sign_extend = ((1 << (temp.itemsize * 8 - bit_count)) - 1) << bit_count
184-
sign_bits = bitwise_and(temp, sign_bit_mask)
185184
temp_u = temp.view('u{}'.format(temp.itemsize))
186-
for idx, sign in enumerate(sign_bits):
187-
if sign:
188-
temp_u[idx] |= sign_extend
185+
negative = bitwise_and(temp_u, sign_bit_mask).astype(bool)
186+
temp_u = where(negative, bitwise_or(temp_u, sign_extend), temp_u)
189187
temp = temp_u.view(temp.dtype)
190188
buf[name] = temp
191189

@@ -1339,7 +1337,7 @@ def generate_chunks(self):
13391337
return chunks
13401338

13411339
def read_all_channels_sorted_record(self, fid):
1342-
""" reads all channels from file using numpy fromstring, chunk by chunk
1340+
""" reads all channels from file using a single numpy frombuffer call
13431341
13441342
Parameters
13451343
------------
@@ -1351,19 +1349,14 @@ def read_all_channels_sorted_record(self, fid):
13511349
rec : numpy recarray
13521350
contains a matrix of raw data in a recarray (attributes corresponding to channel name)
13531351
"""
1354-
chunks = self.generate_chunks()
1355-
previous_index = 0
1356-
buf = recarray(self.numberOfRecords, dtype={'names': self.dataRecordName,
1357-
'formats': self.numpyDataRecordFormat}) # initialise array
1352+
dtype = {'names': self.dataRecordName, 'formats': self.numpyDataRecordFormat}
1353+
total_size = self.CGrecordLength * self.numberOfRecords
13581354
simplefilter('ignore', FutureWarning)
1359-
for n_record_chunk, chunk_size in chunks:
1360-
buf[previous_index: previous_index + n_record_chunk] = \
1361-
frombuffer(fid.read(chunk_size),
1362-
dtype={'names': self.dataRecordName,
1363-
'formats': self.numpyDataRecordFormat},
1364-
count=n_record_chunk)
1365-
previous_index += n_record_chunk
1366-
return buf
1355+
# Read into a flat uint8 buffer, then reinterpret as the structured recarray.
1356+
# Single allocation, no copy, and the result is writeable for in-place conversions.
1357+
raw = empty(total_size, dtype='u1')
1358+
fid.readinto(raw)
1359+
return raw.view(dtype).view(recarray)[:self.numberOfRecords]
13671360

13681361
def read_unique_channel(self, fid, info):
13691362
""" reads all channels from file using numpy fromstring, chunk by chunk

mdfreader/mdfinfo4.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
from .mdf import _open_mdf, dataField, descriptionField, unitField, \
3434
masterField, masterTypeField, idField, _convert_name
3535

36+
try:
37+
from dataRead import SymBufReader as _SymBufReader
38+
_SYMBUF_AVAILABLE = True
39+
except ImportError:
40+
_SYMBUF_AVAILABLE = False
41+
3642
# datatypes
3743
_LINK = '<Q'
3844
_REAL = '<d'
@@ -1793,10 +1799,10 @@ def decompress_data_block(block, zip_type, zip_parameter, org_data_length):
17931799
M = org_data_length // zip_parameter
17941800
aligned = M * zip_parameter
17951801
tail = block[aligned:]
1796-
# frombuffer avoids a copy; .T.copy() materialises the transposed layout
1797-
transposed = frombuffer(block[:aligned], dtype='u1').reshape(zip_parameter, M).T.copy()
1802+
# reshape as (zip_parameter, M), use Fortran-contiguous copy to get row-major of transpose
1803+
arr = frombuffer(block[:aligned], dtype='u1').reshape(zip_parameter, M)
17981804
del block
1799-
block = (transposed.tobytes() + bytes(tail)) if tail else transposed.tobytes()
1805+
block = (arr.T.tobytes() + bytes(tail)) if tail else arr.T.tobytes()
18001806
return block
18011807

18021808
def write(self, fid, data, record_length):
@@ -1902,7 +1908,7 @@ def write(self, fid, data):
19021908

19031909

19041910
class Info4(dict):
1905-
__slots__ = ['fileName', 'fid', 'filterChannelNames', 'zipfile']
1911+
__slots__ = ['fileName', 'fid', 'filterChannelNames', 'zipfile', '_si_cache']
19061912
""" information block parser fo MDF file version 4.x
19071913
19081914
Attributes
@@ -1961,21 +1967,24 @@ def __init__(self, file_name=None, fid=None, filter_channel_names=False, minimal
19611967
self['AT'] = {} # Attachment block
19621968
self['allChannelList'] = set() # all channels
19631969
self['masters'] = dict() # channels grouped by master
1970+
self._si_cache = {} # SI block cache: pointer → dict
19641971
self.filterChannelNames = filter_channel_names
19651972
self.fileName = file_name
19661973
self.fid = None
19671974
if fid is None and file_name is not None:
19681975
# Open file
19691976
(self.fid, self.fileName, self.zipfile) = _open_mdf(self.fileName)
19701977
if self.fileName is not None and fid is None:
1971-
self.read_info(self.fid, minimal)
1978+
reader = _SymBufReader(self.fid) if _SYMBUF_AVAILABLE else self.fid
1979+
self.read_info(reader, minimal)
19721980
# Close the file
19731981
self.fid.close()
19741982
if self.zipfile: # temporary uncompressed file, to be removed
19751983
remove(file_name)
19761984
elif self.fileName is None and fid is not None:
19771985
# called by mdfreader.mdfinfo
1978-
self.read_info(fid, minimal)
1986+
reader = _SymBufReader(fid) if _SYMBUF_AVAILABLE else fid
1987+
self.read_info(reader, minimal)
19791988

19801989
def read_info(self, fid, minimal):
19811990
""" read all file blocks except data
@@ -2252,12 +2261,15 @@ def read_cn_block(self, fid, pointer, dg, cg, mlsd_channels, vlsd, minimal, chan
22522261
fid, self['CN'][dg][cg][cn]['cn_cc_conversion'])
22532262
if not channel_name_list:
22542263
if not minimal:
2255-
# reads Channel Source Information
2256-
temp = SIBlock()
2257-
temp.read_si(fid, self['CN'][dg][cg][cn]['cn_si_source'])
2258-
if temp:
2259-
self['CN'][dg][cg][cn]['SI'] = dict()
2260-
self['CN'][dg][cg][cn]['SI'].update(temp)
2264+
# reads Channel Source Information (cached by pointer to avoid duplicate file reads)
2265+
si_pointer = self['CN'][dg][cg][cn]['cn_si_source']
2266+
if si_pointer and si_pointer not in self._si_cache:
2267+
temp = SIBlock()
2268+
temp.read_si(fid, si_pointer)
2269+
self._si_cache[si_pointer] = dict(temp) if temp else None
2270+
cached_si = self._si_cache.get(si_pointer)
2271+
if cached_si:
2272+
self['CN'][dg][cg][cn]['SI'] = dict(cached_si)
22612273

22622274
# keep original non unique channel name
22632275
self['CN'][dg][cg][cn]['orig_name'] = self['CN'][dg][cg][cn]['name']

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919

2020
name = 'mdfreader'
21-
version = '4.2'
21+
version = '4.3'
2222

2323
description = 'A Measured Data Format file parser'
2424

0 commit comments

Comments
 (0)