Skip to content

Commit e446c1d

Browse files
authored
Merge pull request #31 from ezmsg-org/cboulay/spike_mlx
ThresholdCrossing: dense output + auto-routed MLX/Metal kernel
2 parents 6c3cfe1 + f364114 commit e446c1d

6 files changed

Lines changed: 756 additions & 127 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ readme = "README.md"
88
requires-python = ">=3.10"
99
dynamic = ["version"]
1010
dependencies = [
11-
"ezmsg>=3.7.3",
12-
"ezmsg-baseproc>=1.5.1",
13-
"ezmsg-sigproc>=2.17.0",
11+
"ezmsg>=3.9.0",
12+
"ezmsg-baseproc>=1.10.2",
13+
"ezmsg-sigproc>=2.22.0",
1414
"sparse>=0.17.0",
1515
"numpy>=2.2.6",
1616
]

src/ezmsg/event/kernel_activation.py

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
"""
2-
Compute binned kernel activation from sparse events.
2+
Compute binned kernel activation from events.
33
44
This module provides efficient computation of kernel-convolved features
55
at a lower output rate than the input. For exponential and alpha kernels,
66
uses a state-based approach that is O(n_events + n_bins) instead of
77
O(n_samples).
8+
9+
Input may be either ``sparse.COO`` (the typical output of
10+
:class:`ezmsg.event.peak.ThresholdCrossingTransformer` in default mode) or a
11+
dense array (from the same transformer with ``output_format=DENSE``). When the
12+
input is dense and the configuration is COUNT + SUM (the rate-computation
13+
case), the binning runs on the input's array namespace and stays on device
14+
(e.g., MLX, CuPy). Other configurations with dense input fall back to
15+
event-extraction and use the same code path as sparse input.
816
"""
917

1018
from enum import Enum
1119

1220
import ezmsg.core as ez
1321
import numpy as np
1422
import numpy.typing as npt
23+
import sparse
24+
from array_api_compat import get_namespace, is_numpy_array
1525
from ezmsg.baseproc import BaseStatefulTransformer, BaseTransformerUnit, processor_state
1626
from ezmsg.util.messages.axisarray import AxisArray, replace
1727

@@ -122,7 +132,8 @@ def _hash_message(self, message: AxisArray) -> int:
122132
n_channels = message.data.shape[message.get_axis_idx("ch")] if "ch" in message.dims else 1
123133
if "time" not in message.axes or not hasattr(message.axes["time"], "gain"):
124134
raise ValueError("Could not determine sample rate from input message")
125-
return hash((message.data.ndim, message.data.dtype.kind, n_channels, message.axes["time"].gain))
135+
# str(dtype) works for numpy ('bool', 'float32', ...) and mlx (which doesn't expose dtype.kind).
136+
return hash((message.data.ndim, str(message.data.dtype), n_channels, message.axes["time"].gain))
126137

127138
def _reset_state(self, message: AxisArray) -> None:
128139
"""Initialize state for new input stream."""
@@ -197,6 +208,31 @@ def _get_activation_at_sample(self, channel: int, sample: int) -> float:
197208
return self._state.activation[channel]
198209

199210
def _process(self, message: AxisArray) -> AxisArray:
211+
"""Compute binned activation from sparse or dense event input.
212+
213+
Dispatch:
214+
- Dense input + COUNT + SUM: fast path that stays on the input's array
215+
namespace (e.g., MLX, CuPy on device).
216+
- Dense input + any other config: extract events from non-zero entries
217+
and use the same code path as sparse input.
218+
- Sparse input: existing event-based path.
219+
"""
220+
data = message.data
221+
is_sparse_input = isinstance(data, sparse.SparseArray)
222+
223+
if not is_sparse_input:
224+
if (
225+
self.settings.kernel_type == ActivationKernelType.COUNT
226+
and self.settings.aggregation == BinAggregation.SUM
227+
):
228+
return self._process_dense_count_sum(message)
229+
# Fall back: convert dense to sparse so the existing event-based path can run.
230+
data_np = data if is_numpy_array(data) else np.asarray(data)
231+
message = replace(message, data=sparse.COO.from_numpy(data_np))
232+
233+
return self._process_events(message)
234+
235+
def _process_events(self, message: AxisArray) -> AxisArray:
200236
"""Compute binned activation from sparse events."""
201237
sparse_data = message.data
202238
n_samples = sparse_data.shape[0]
@@ -348,6 +384,105 @@ def _process(self, message: AxisArray) -> AxisArray:
348384
},
349385
)
350386

387+
def _process_dense_count_sum(self, message: AxisArray) -> AxisArray:
388+
"""Fast path: dense input + COUNT kernel + SUM aggregation.
389+
390+
Bins are summed using cumulative-sum arithmetic in the input's array
391+
namespace, so accelerator-resident inputs (MLX, CuPy) stay on device.
392+
Carry-over for the partial bin spanning chunk boundaries is held in
393+
``state.activation`` (numpy) and shuttled across boundaries.
394+
"""
395+
xp = get_namespace(message.data)
396+
data = message.data
397+
n_samples = data.shape[0]
398+
feature_shape = tuple(data.shape[1:])
399+
400+
samples_per_bin = self.settings.bin_duration * self._state.fs
401+
accumulator_before = self._state.bin_accumulator
402+
total_samples = n_samples + accumulator_before
403+
n_bins = int(total_samples / samples_per_bin)
404+
405+
# Per-sample contribution: 1 per non-zero, or the value itself if scaling.
406+
# Use the .astype() method form so the same call works for both numpy and mlx
407+
# (mlx.core has no top-level astype).
408+
if n_samples == 0:
409+
contrib = xp.zeros((0,) + feature_shape, dtype=xp.float32)
410+
elif self.settings.scale_by_value:
411+
contrib = data.astype(xp.float32)
412+
else:
413+
contrib = (data != 0).astype(xp.float32)
414+
415+
# Pull state into the input namespace for on-device math.
416+
overflow_xp = xp.asarray(self._state.activation.reshape(feature_shape)).astype(xp.float32)
417+
418+
if n_bins == 0:
419+
# No complete bins this chunk — accumulate everything into the carry-over.
420+
new_overflow = overflow_xp + (xp.sum(contrib, axis=0) if n_samples > 0 else overflow_xp * 0)
421+
self._state.activation = np.asarray(new_overflow).reshape(self._state.activation.shape)
422+
self._state.bin_accumulator = total_samples
423+
return replace(
424+
message,
425+
data=xp.zeros((0,) + feature_shape, dtype=xp.float32),
426+
axes={
427+
**message.axes,
428+
"time": replace(message.axes["time"], gain=self.settings.bin_duration),
429+
},
430+
)
431+
432+
# Bin boundaries (in input-sample space, integer-truncated as in the event-based path).
433+
first_bin_end = samples_per_bin - accumulator_before
434+
bin_ends_float = first_bin_end + np.arange(n_bins) * samples_per_bin
435+
bin_end_samples = bin_ends_float.astype(np.int64)
436+
bin_start_samples = np.concatenate(([np.int64(0)], bin_end_samples[:-1]))
437+
438+
# Cumulative sum, prepended with zeros so cumsum_padded[k] = sum(contrib[:k]).
439+
# Use cumsum (in both numpy and mlx); numpy via array_api_compat also exposes
440+
# the standard `cumulative_sum`, but mlx does not.
441+
cumsum = xp.cumsum(contrib, axis=0)
442+
zero_row = xp.zeros((1,) + feature_shape, dtype=cumsum.dtype)
443+
cumsum_padded = xp.concat((zero_row, cumsum), axis=0)
444+
445+
end_idx = xp.asarray(bin_end_samples)
446+
start_idx = xp.asarray(bin_start_samples)
447+
bin_sums = xp.take(cumsum_padded, end_idx, axis=0) - xp.take(cumsum_padded, start_idx, axis=0)
448+
449+
# Add carry-over from the previous chunk's partial bin into bin 0.
450+
overflow_pad_first = overflow_xp[None, ...]
451+
if n_bins > 1:
452+
overflow_pad_rest = xp.zeros((n_bins - 1,) + feature_shape, dtype=bin_sums.dtype)
453+
overflow_pad = xp.concat((overflow_pad_first, overflow_pad_rest), axis=0)
454+
else:
455+
overflow_pad = overflow_pad_first
456+
output = bin_sums + overflow_pad
457+
458+
# New carry-over: events past the last complete bin remain in the partial bin.
459+
last_bin_end = int(bin_end_samples[-1])
460+
if last_bin_end < n_samples:
461+
new_overflow = xp.sum(contrib[last_bin_end:], axis=0)
462+
else:
463+
new_overflow = xp.zeros(feature_shape, dtype=cumsum.dtype)
464+
self._state.activation = np.asarray(new_overflow).reshape(self._state.activation.shape)
465+
self._state.bin_accumulator = total_samples - n_bins * samples_per_bin
466+
467+
if self.settings.rate_normalize:
468+
output = output / self.settings.bin_duration
469+
470+
accumulator_time = accumulator_before / self._state.fs
471+
input_offset = message.axes["time"].offset if "time" in message.axes else 0.0
472+
output_offset = input_offset - accumulator_time
473+
474+
return replace(
475+
message,
476+
data=output,
477+
axes={
478+
**message.axes,
479+
"time": AxisArray.TimeAxis(
480+
fs=1.0 / self.settings.bin_duration,
481+
offset=output_offset,
482+
),
483+
},
484+
)
485+
351486

352487
class BinnedKernelActivationUnit(
353488
BaseTransformerUnit[

0 commit comments

Comments
 (0)