Skip to content

Reading Blackrock recordings from fsspec URLs: is remote reading in scope for rawio? #1931

Open

Description

@cboulay

We convert Blackrock recordings (NSx, NEV, PTP) that live in cloud blob storage. Today that means downloading each file first. We have a working implementation of BlackrockRawIO that reads straight from an fsspec URL (abfs://, s3://, ...) with byte-range reads, and we would like to upstream what you are willing to take. Before opening PRs we would like to know whether remote reading is something rawio should support at all, because neo is built around local files and memory maps and nothing in rawio reads remote files today (the only fsspec use I found is storage_options for kerchunk in xarray_utils.py).

What we have

  • BlackrockRawIO(filename="abfs://container/rec", storage_options={...}). A name containing :// goes through fsspec.core.url_to_fs; anything else behaves exactly as now. fsspec stays an optional import (a neo[remote] extra).
  • Local files keep the np.memmap path unchanged. The remote path copies byte ranges instead, because a remote file cannot be memory-mapped.
  • Headers, signal chunks (standard and PTP) and .nev packets are read as byte ranges. A .nev is scanned in pieces instead of being read whole.
  • On every Blackrock test file in ephy_testing_data, remote and local reads agree: headers, all signal chunks, timestamps, spikes, waveforms and events, including .nev scans in pieces as small as one packet. (Tests use an fsspec filesystem over the same local files, so CI needs no credentials.)
  • Memory: opening and streaming a 2.43 GB .nev through fsspec peaked at 0.32 GB, against 4.63 GB for the existing local code path.

Proposed split, smallest first

  1. Generic file access (small, reusable by other rawios): helpers in rawio/utils.py to read records and strided values from an open file, plus an optional-fsspec opener. No change to BaseRawIO.
  2. Blackrock streaming: chunked reads without mmap, PTP over a URL, .nev streaming. Blackrock-specific, most of the code.
  3. iter_spikes(): new public API that streams every unit's spikes in one pass. The per-unit methods each have to read the whole .nev, so a remote file would otherwise be read once per unit. This is the part we expect to need the most discussion.

Independent of this, we have sent or are sending small bug fixes as separate PRs (a .nev over 4 GiB failing under NumPy 2: #1929; the has_buffer_description_api() check: #1930).

Questions

  1. Is remote reading in scope for rawio, or do you see it as out of scope for neo?
  2. If it is: should it be opt-in per rawio (what we did for Blackrock), or a BaseRawIO feature (a storage_options argument, with file access going through the base class)? Per-rawio is much smaller, since 21 rawio modules use np.memmap, np.fromfile or mmap directly, but it leaves other formats to adopt it one by one.
  3. Would you accept iter_spikes() as new rawio API? If not, would you prefer we keep it out of neo?
  4. Is the extra maintenance of a remote code path inside blackrockrawio.py (about 700 added lines, tested against local files) acceptable? If not, we will maintain it in a fork and only send the generic pieces.

Happy to split or drop any part.

Activity

  1. zm711 commented on Oct 24, 2026

    @zm711
    Contributor

    @cboulay thanks for bringing this up. I think traditionally we've been doing most remote reading through spikeinterface rather than directly in Neo. What do you think @h-mayorquin and @alejoe91 ?

  2. h-mayorquin commented on Oct 28, 2026

    @h-mayorquin
    Contributor

    Thanks for writing this up so carefully and for asking before opening the PRs (and for measuring the memory!). I thought a lot about this before answering. These are my answers, @alejoe91 or @samuelgarcia might see some of them differently: 1) I would not add remote reading to rawio yet, I would like to get the reader closer first. 2) If we do it, I would prefer something general for all the readers instead of an opt-in per reader, but the interface is an open question for the other maintainers. 3) I would not add iter_spikes() as Blackrock-only API (see the lazy waveforms below for the per-unit reads). 4) For now I would prefer not to maintain a second code path in blackrockrawio.py, so your second option of keeping the remote path in your fork and sending the generic pieces sounds good to me.

    The reason is that I tried to move the Blackrock reader in this direction in #1820 (describing the buffers instead of keeping memmaps open) but I found the following challenges:

    • The standard formats can store several data blocks in one file, each one with its own header. A buffer description is one contiguous region, so it only works while every segment is a single data block. The gap tolerance in BlackrockRawIO: extend gap_tolerance_ms to the standard nsx formats #1880 merges blocks into one segment and then a segment is no longer one region.
    • PTP files interleave a timestamp with every sample. The samples are not one plain region, and finding the gaps requires reading every timestamp.
    • The .nev has no index. To know which units each electrode has and where the clock resets are, every packet has to be read at parse.

    Did you manage to overcome them?

    What I would like to do is to move forward in that direction slowly by doing small improvements such as:

    I think this will make your own solution simpler. It should also get the reader to a point where the changes needed for remote reading are minimal and then we can focus the discussion on a robust design for it.

    If you have time, it would be great if you could review #1940 and #1880. In #1880 the PTP part touches the same code as the reordering you mentioned in #1937.

  3. cboulay commented on Oct 28, 2026

    @cboulay
    ContributorAuthor

    Thanks Heberto, that plan works for me: remote reading stays in our fork for now, and I'll send the generic pieces in small PRs. On your three challenges, here's where our fork landed. We worked around them rather than solving them in general:

    • Several data blocks per file: we describe standard NSx buffers one data block per region. A segment that merges blocks (as in BlackrockRawIO: extend gap_tolerance_ms to the standard nsx formats #1880) can't be one region, so for those we'd return no buffer description and fall back to chunked reads, rather than invent a multi-region format.
    • PTP timestamps per sample: we don't describe PTP as a buffer either. We read the timestamps at parse in bounded chunks, which is unavoidable to find gaps, and that same pass is where we detect packets written out of order (Are the segments of a block ordered in time? #1937). Memory stays flat, which is what lets us parse large files on 5 GB runners.
    • No index in the .nev: one streaming pass at parse counts spikes per unit and finds resets, and iter_spikes() streams them afterwards. Your table in BlackrockRawIO: read .nev spike waveforms lazily instead of copying them at parse #1941 is a cleaner version of the same idea behind the normal public API. I'd rather adopt it and drop iter_spikes() than add Blackrock-only API.

    I've reviewed #1940 and #1880.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions