Skip to content

Commit 5c479a9

Browse files
authored
Build the NwbSortingExtractor spike vector with a single bulk read (#4662)
1 parent f3cc182 commit 5c479a9

1 file changed

Lines changed: 65 additions & 11 deletions

File tree

src/spikeinterface/extractors/nwbextractors.py

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from spikeinterface import get_global_tmp_folder
1010
from spikeinterface.core import BaseRecording, BaseRecordingSegment, BaseSorting, BaseSortingSegment
11+
from spikeinterface.core.base import minimum_spike_dtype
1112
from spikeinterface.core.core_tools import define_function_from_class
1213

1314
if importlib.util.find_spec("pynwb") is not None:
@@ -1426,6 +1427,34 @@ def __init__(
14261427
"t_start": self.t_start,
14271428
}
14281429

1430+
def _compute_and_cache_spike_vector(self) -> None:
1431+
# Performance: this deliberately overrides the generic BaseSorting builder, and the override is
1432+
# the point. The generic builder reads one spike train per unit (one streamed read per unit),
1433+
# whereas an NWB Units table stores every unit's spikes in a single flat times dataset grouped by
1434+
# unit. Reading that dataset once (see NwbSortingSegment._get_all_spike_samples_and_unit_indices)
1435+
# turns num_units streamed reads into one, which is a large difference for remote/streamed files.
1436+
# Do not drop this override in favor of the generic path without a reason: doing so silently
1437+
# reintroduces the per-unit read pattern. An NWB Units table is always single-segment, so the
1438+
# single segment is built directly.
1439+
segment = self.segments[0]
1440+
sample_indices, unit_indices = segment._get_all_spike_samples_and_unit_indices()
1441+
1442+
# NWB stores spike_times grouped by unit, not globally ordered by time: pynwb itself notes that
1443+
# "the earliest spike may be in any unit" (Units.get_earliest_spike_time), and within-unit ordering
1444+
# is only a best-practice (checked by nwbinspector), not a schema guarantee. So a global sort is
1445+
# required to build the time-ordered spike vector. It is stable so unit_index order is preserved on
1446+
# equal sample_index ties, matching the generic BaseSorting builder; on already-ordered input the
1447+
# sort is a no-op (argsort returns the identity permutation).
1448+
order = np.argsort(sample_indices, stable=True)
1449+
num_spikes = sample_indices.size
1450+
1451+
spikes = np.zeros(num_spikes, dtype=minimum_spike_dtype)
1452+
spikes["sample_index"] = sample_indices[order]
1453+
spikes["unit_index"] = unit_indices[order]
1454+
# segment_index stays 0 for the single segment.
1455+
self._cached_spike_vector = spikes
1456+
self._cached_spike_vector_segment_slices = np.array([[0, num_spikes]], dtype="int64")
1457+
14291458
@staticmethod
14301459
def fetch_available_units_tables(
14311460
file_path: str | Path,
@@ -1468,6 +1497,18 @@ def _frame_to_time(self, frame):
14681497
return self._time_vector[frame]
14691498
return frame / self._sampling_frequency + self._t_start
14701499

1500+
def _times_to_samples(self, spike_times) -> np.ndarray:
1501+
# Map seconds to samples. An irregular clock (per-sample timestamps) maps exactly with a
1502+
# searchsorted into the time vector; a uniform clock uses sample = (t - t_start) * sampling_frequency.
1503+
if self._time_vector is not None:
1504+
# searchsorted needs the timestamps in memory: materialize them once, on first use, and cache.
1505+
if not isinstance(self._time_vector, np.ndarray):
1506+
self._time_vector = np.asarray(self._time_vector)
1507+
samples = np.searchsorted(self._time_vector, spike_times, side="right") - 1
1508+
else:
1509+
samples = np.round((spike_times - self._t_start) * self._sampling_frequency)
1510+
return samples.astype("int64", copy=False)
1511+
14711512
def get_unit_spike_train(
14721513
self,
14731514
unit_id,
@@ -1481,16 +1522,29 @@ def get_unit_spike_train(
14811522
# Get spike times in seconds
14821523
spike_times = self.get_unit_spike_train_in_seconds(unit_id=unit_id, start_time=start_time, end_time=end_time)
14831524

1484-
# Map seconds to samples. An irregular clock (per-sample timestamps) maps exactly with a
1485-
# searchsorted into the time vector; a uniform clock uses frame = (t - t_start) * sampling_frequency.
1486-
if self._time_vector is not None:
1487-
# searchsorted needs the timestamps in memory: materialize them once, on first use, and cache.
1488-
if not isinstance(self._time_vector, np.ndarray):
1489-
self._time_vector = np.asarray(self._time_vector)
1490-
frames = np.searchsorted(self._time_vector, spike_times, side="right") - 1
1491-
else:
1492-
frames = np.round((spike_times - self._t_start) * self._sampling_frequency)
1493-
return frames.astype("int64", copy=False)
1525+
return self._times_to_samples(spike_times)
1526+
1527+
def _get_all_spike_samples_and_unit_indices(self):
1528+
"""Read all units' spike trains (as samples) in one bulk read, plus each spike's unit index.
1529+
1530+
NWB stores all spike times in a single flat dataset grouped by unit (with per-unit
1531+
boundaries in ``spike_times_index_data``), so the whole segment is read at once instead
1532+
of one streamed slice per unit.
1533+
"""
1534+
spike_times = np.asarray(self.spike_times_data[:], dtype="float64")
1535+
# NWB stores a ragged column as one flat data array (spike_times, all units concatenated) plus an
1536+
# index array of cumulative end offsets (spike_times_index): spike_times_index[u] is where unit u's
1537+
# spikes end and unit u+1's begin, so unit u is spike_times[spike_times_index[u-1] : spike_times_index[u]].
1538+
# Prepending a 0 gives every unit an explicit start, so boundaries[u]:boundaries[u+1] is unit u's
1539+
# slice; the gap between consecutive boundaries (np.diff) is that unit's spike count, and repeating
1540+
# each unit index by its count labels every spike with its unit.
1541+
# Example: 3 units with 2, 0, 3 spikes -> spike_times_index = [2, 2, 5],
1542+
# boundaries = [0, 2, 2, 5], counts = [2, 0, 3], unit_indices = [0, 0, 2, 2, 2].
1543+
boundaries = np.concatenate(([0], np.asarray(self.spike_times_index_data[:], dtype="int64")))
1544+
counts = np.diff(boundaries)
1545+
unit_indices = np.repeat(np.arange(counts.size, dtype="int64"), counts)
1546+
sample_indices = self._times_to_samples(spike_times)
1547+
return sample_indices, unit_indices
14941548

14951549
def get_unit_spike_train_in_seconds(
14961550
self,
@@ -1501,7 +1555,7 @@ def get_unit_spike_train_in_seconds(
15011555
"""Get the spike train times for a unit in seconds.
15021556
15031557
This method returns spike times directly in seconds without conversion
1504-
to frames, avoiding double conversion for NWB files that already store
1558+
to samples, avoiding double conversion for NWB files that already store
15051559
spike times as timestamps.
15061560
15071561
Parameters

0 commit comments

Comments
 (0)