Skip to content

Move Blackrock nsx handling closer to the buffer API pattern#1820

Open
h-mayorquin wants to merge 10 commits into
NeuralEnsemble:masterfrom
h-mayorquin:add_buffer_api_like_behavior_for_blackrock
Open

Move Blackrock nsx handling closer to the buffer API pattern#1820
h-mayorquin wants to merge 10 commits into
NeuralEnsemble:masterfrom
h-mayorquin:add_buffer_api_like_behavior_for_blackrock

Conversation

@h-mayorquin

Copy link
Copy Markdown
Contributor

PR #1513 introduced BaseRawWithBufferApiIO, a base class that stores lightweight buffer descriptions (file paths, offsets, dtypes) instead of persistent np.memmap objects, creating short-lived mmap views on demand through cached file descriptors. This PR moves BlackrockRawIO toward that same pattern. We cannot fully adopt BaseRawWithBufferApiIO because: (a) Blackrock's standard format (v2.2/2.3/3.0) stores multiple data blocks with explicit headers in a single file, requiring block-aware offset tracking that the base class's one-buffer-per-stream model does not support; (b) the PTP variant interleaves per-sample timestamps with sample data in fixed-size packets, requiring strided mmap access (np.ndarray with custom strides) rather than contiguous views; and (c) the segmentation logic for PTP files needs to read timestamps from the file to detect gaps, which couples parsing and segmentation in a way the base class does not allow. However, we can adopt the core idea: replace stored memmaps with on-demand creation.

This has the added benefit of reducing the number of memmaps that are open at any time and we have seen that this pattern is both 1) more performant memory wise, 2) less likely to lead to leaks. Importantly, this PR does not change any behavior of the reader, it is an internal optimization only. It lays the groundwork for a follow-up PR adding gap detection on the standard format.

@h-mayorquin h-mayorquin self-assigned this Feb 17, 2026
@h-mayorquin h-mayorquin changed the title Blackrock nsx closer to the buffer API pattern Move Blackrock nsx handling closer to the buffer API pattern Feb 17, 2026
@alejoe91 alejoe91 added this to the 0.14.5 milestone Mar 25, 2026
@alejoe91 alejoe91 modified the milestones: 0.14.5, 0.14.6 Jun 24, 2026

@zm711 zm711 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initial round of comments. I'm not as familiar with this reader so might need a bit more explanation in general @h-mayorquin.

strides=strides,
)

def _get_nsx_fid(self, nsx_nb):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you walk me through how this construction is going to happen? Shouldn't we grab all nsx_fids at one time to construct them. This calls and caches the fids at it goes right? creating the dict on the first call?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've got it right, it's lazy. On the first call it creates the fid dict, then for each nsx number it opens the file and caches the handle on the first miss, returning the cached handle on every subsequent call. So the handles are built up one at a time as they're first needed, not all at once.

I kept it lazy deliberately to mirror the buffer API's own file-handle pattern, where the multiplexed path opens each buffer's fid on demand, caches it, and reuses it on later calls rather than opening everything upfront. This is the same convention keyed by nsx number.

It also genuinely defers work here. Only PTP files open during header parsing; non-PTP files don't open until the first data read. So a reader that just inspects the header, or only reads one stream, never opens files it doesn't touch. The fail-fast case is already covered by the missing-file check during parsing, which validates the requested set exists before we reach this point.

"""

import datetime
import mmap

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the benefit of mmap vs np.memmap?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in our in-person discussion this is the pattern that we use in the buffer array API and we use it for a reason: it behaves better with the python garbage collector.

I don't think either me or Sam understand that one that well but my guess is (with emphasis on guess) np.memmap slices keep references to the original memmap/file whereas np.ndarray(buffer=...) does not.

Comment thread neo/rawio/blackrockrawio.py Outdated
memmap_data = self.nsx_datas[nsx_nb][seg_index]
seg = self._nsx_data_header[nsx_nb][seg_index]
fid = self._get_nsx_fid(nsx_nb)
kw = seg["memmap_kwargs"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we get a better variable name for readability?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be done.

Comment thread neo/rawio/blackrockrawio.py Outdated
# Extract data and timestamps from structured array
data = file_memmap["samples"]
timestamps = file_memmap["timestamps"]
del temp_memmap

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we deleting the temp_memmap twice?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no longer here.

Comment thread neo/rawio/blackrockrawio.py Outdated
"timestamp": timestamps, # Use singular for backward compatibility
"nb_data_points": data.shape[0],
segments.append({
"timestamp": block_info["timestamps"], # Use singular for backward compatibility

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know what this backward compatibility is?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for checking this.

I dug into this and there was no real backward compatibility being preserved, the comment was misleading. The value under that key has always been a single scalar (one timestamp per block), and the only thing consuming the plural "timestamps" key was the segmentation step, which immediately renamed it back to the singular "timestamp" that the rest of the reader already uses. It never crossed a module boundary or fed the per-sample timestamps path. So it was just an inconsistent name from the refactor, and I collapsed it to the singular key that matches the value's actual cardinality and dropped the comment.

Comment thread neo/rawio/blackrockrawio.py Outdated
ev_ids[ev_ids == j] -= 1
# Remap nev segment ids: shift down all ids above the removed segment
if self._avail_files["nev"]:
for _key, (data, ev_ids) in self.nev_data.items():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where is _key even being used?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's switch to values, this is not used.

Comment thread neo/rawio/blackrockrawio.py Outdated

# Check all nonempty nsX segments
for i, seg in enumerate(list_nonempty_nsx_segments[:]):
for i, seg in enumerate(nonempty_nsx_segments[:]):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're already changing names of stuff want to change i into something more interpretable for future us?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hes. Good idea. Done.

keep_seg = True
for nsx_nb in self.nsx_to_load:
length = self.nsx_datas[nsx_nb][data_bl].shape[0]
length = self._nsx_data_header[nsx_nb][seg_index]["nb_data_points"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something I keep seeing. Why didn't the original implementation use this nb_data_points value? It seems to have been present before. They seem to want to use the shape of the data. Is it possible that the dict value is ever off from the actual data shape?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original used .shape[0] because the old reader eagerly memmapped every segment into self.nsx_datas, so the array was already in hand and .shape[0] was the natural way to get the sample count. The buffer-API refactor doesn't eager-map anymore, it stores memmap_kwargs and maps on demand, so there's no materialized array here to measure, and reading the stored nb_data_points is the equivalent.

My thinking:
It can't drift from the actual shape, because both come from the same source: the block header's nb_data_points field for standard formats, the filesize computation for v2.1, or the PTP gap-segment length. The data view's shape is derived from that same number rather than measured independently, so they're equal by construction. On master it actually round-tripped, nb_data_points = data.shape[0] where the memmap was created with that same count, so .shape[0] was never independent ground truth either.

data_header = self._nsx_data_header[nsx_nb].pop(j)
self._nsx_data_header[nsx_nb][j - 1] = data_header
# Remove empty segments in reverse order to preserve indices
for seg_index in reversed(empty_indices):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice switch to reversed

# Remap nev segment ids: shift down all ids above the removed segment
if self._avail_files["nev"]:
for _key, (data, ev_ids) in self.nev_data.items():
ev_ids[ev_ids > seg_index] -= 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the other bit I was getting a lost at. So you check for > seg_index, but they seem to be only checking that j is equal. So let me double check that I understand correctly. Previously they were doing a nested loop with the j and the ev_ids and then basically it would iterate one value per loop. What you are doing is doing only one loop through the data and changing all applicable values during that one loop, right? If that's not the correct intuition than I might need you to explain this bit to me.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your intuition is exactly right. The old code had the inner j loop and decremented ev_ids[ev_ids == j] one index per iteration; the new code does it in a single vectorized ev_ids[ev_ids > seg_index] -= 1. The reason it can is that the segment container changed from a dict keyed by segment index to a list. On master, removing a segment meant manually re-keying every higher segment j -> j-1 in an inner loop, and the nev remap was interleaved into that loop, hence == j per pass. With a list, del [seg_index] shifts everything down automatically, so there's no inner loop and the nev ids above the removed segment can all be shifted at once. Same result either way: every id above the removed segment drops by one.

Comment thread neo/rawio/blackrockrawio.py Outdated
file_memmap = np.memmap(filename, dtype=ptp_dt, shape=npackets, offset=header_size, mode="r")
ptp_dtype = np.dtype(ptp_dt)
npackets = int((filesize - header_size) / ptp_dtype.itemsize)
temp_memmap = np.memmap(filename, dtype=ptp_dt, shape=npackets, offset=header_size, mode="r")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be changed to mmap?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, and here, I was using this to check for ptp and I actually don't think we need this. So changing this.

@h-mayorquin

Copy link
Copy Markdown
Contributor Author

All the comments should be addressed now @zm711 . Let me know if thereis anything else.

@zm711

zm711 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Let me reread this tomorrow and then I'll merge barring anything I find.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants