Repository navigation
Faster exact median for common_reference
#4854
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
galenlynch
wants to merge
7
commits into
SpikeInterface:main
Choose a base branch
from
galenlynch:perf/cmr-row-median
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+221
−1
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
a8b634c
perf: exact numba row median for common_reference
galenlynch 5e4f8ef
Merge branch 'main' into perf/cmr-row-median
alejoe91 ff73ac5
docs: explain the row median algorithms in comments
galenlynch beec896
perf: sort float rows for the median instead of the counting kernel
galenlynch 7318c58
Merge branch 'main' into perf/cmr-row-median
alejoe91 83a186c
chore: credit @JESUSROYETH for sorting float rows
galenlynch d967a2f
fix: send byte-swapped integer traces to np.median in row_median
galenlynch File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| """Exact per-row median of a ``(T, C)`` array, for common median referencing. | ||
|
|
||
| ``np.median(axis=1)`` partitions each row with data-dependent branches, mispredicting about one per value. | ||
| Float rows are sorted instead, since ``np.sort`` vectorises 32- and 64-bit sorts on most current CPUs. Integer | ||
| rows of at most 16 bits, whose sorts it vectorises only on some, go through the histogram kernel in | ||
| ``_row_median_numba``. Both equal ``np.median``, including for float rows holding NaN or inf. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import importlib.util | ||
| from functools import cache | ||
|
|
||
| import numpy as np | ||
|
|
||
| HAVE_NUMBA = importlib.util.find_spec("numba") is not None | ||
|
|
||
| # Histogram bins; a row whose middle values fall outside the window is sorted instead. | ||
| _INT_WINDOW = 1024 | ||
| # Below these channel counts, np.median's partition costs less than the kernel's per-row setup or the sort. | ||
| _MIN_INT_CHANNELS = 4 | ||
| _MIN_FLOAT_CHANNELS = 3 | ||
|
|
||
|
|
||
| def row_median(traces: np.ndarray) -> np.ndarray: | ||
| """Median of each row of a 2D array, as ``(T, 1)`` in ``np.median``'s output dtype. | ||
|
|
||
| float32 and float64 input of at least 3 channels is sorted. Native-endian integer input of at most 16 bits | ||
| and at least 4 channels uses a numba kernel when numba is installed. Other input uses ``np.median``. | ||
| """ | ||
| if traces.ndim == 2 and traces.shape[0] > 0: | ||
| num_channels = traces.shape[1] | ||
| if traces.dtype in (np.float32, np.float64) and num_channels >= _MIN_FLOAT_CHANNELS: | ||
| return _sorted_row_median(traces) | ||
| if ( | ||
| HAVE_NUMBA | ||
| and traces.dtype.kind in "iu" | ||
| and traces.dtype.itemsize <= 2 | ||
| and traces.dtype.isnative # numba rejects byte-swapped arrays | ||
| and num_channels >= _MIN_INT_CHANNELS | ||
| ): | ||
| # The kernel reads column-major traces in place, which beats copying them. | ||
| return _get_int_kernel()(traces, _INT_WINDOW)[:, np.newaxis] | ||
| return np.median(traces, axis=1, keepdims=True) | ||
|
|
||
|
|
||
| def _sorted_row_median(traces): | ||
| num_channels = traces.shape[1] | ||
| sorted_traces = np.sort(traces, axis=1) # NaN sorts last | ||
| lo = sorted_traces[:, (num_channels - 1) // 2] | ||
| hi = sorted_traces[:, num_channels // 2] | ||
| median = 0.5 * (lo + hi) # the scalar stays weak, so the sum and result keep the input dtype | ||
| median[np.isnan(sorted_traces[:, -1])] = np.nan # np.median returns NaN for a row holding one | ||
| return median[:, np.newaxis] | ||
|
|
||
|
|
||
| @cache | ||
| def _get_int_kernel(): | ||
| from ._row_median_numba import int_row_median | ||
|
|
||
| return int_row_median |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| """numba kernel for ``_row_median``, kept at module level so that ``cache=True`` can find it again. | ||
|
|
||
| ``int_row_median`` returns exactly what ``np.median(x, axis=1)`` returns for integer ``x``. For a row of ``C`` | ||
| values, sorted, the median is the mean of the values at ranks ``r_lo = (C - 1) // 2`` and ``r_hi = C // 2``, | ||
| which are the same rank when ``C`` is odd. | ||
|
|
||
| The kernel counts each row into a histogram instead of sorting it. Filtered integer traces take few distinct | ||
| values, and the median moves little from one sample to the next, so a window of bins centred on the previous | ||
| row's median almost always holds both middle values. A cumulative sum over the bins then gives them directly. | ||
| Rows whose middle values fall outside the window are sorted in full, so every path returns the exact median. | ||
| """ | ||
|
|
||
| import numpy as np | ||
| from numba import njit | ||
|
|
||
|
|
||
| @njit(nogil=True, cache=True) | ||
| def int_row_median(x, window): | ||
| """Median of each row of integer ``x`` (at most 16 bits), as float64 like ``np.median``. | ||
|
|
||
| ``window`` is the number of histogram bins. Bin ``i`` counts the value ``base + i``, and ``base`` is chosen | ||
| per row so that the previous row's median sits in the middle bin. | ||
| """ | ||
| T, C = x.shape | ||
| out = np.empty(T, dtype=np.float64) | ||
| counts = np.zeros(window, dtype=np.int32) | ||
| r_lo = (C - 1) // 2 # the two middle ranks, equal for odd C | ||
| r_hi = C // 2 | ||
| center = np.int64(np.sort(x[0])[r_lo]) | ||
| for t in range(T): | ||
| row = x[t] | ||
| base = center - window // 2 | ||
| # Count every value into its bin. Values below the window are only counted, since the rank of anything | ||
| # inside the window depends on how many lie below it; values above it can be ignored. | ||
| below = 0 | ||
| vmin = np.int64(window) # lowest occupied bin, where the cumulative scan starts | ||
| for c in range(C): | ||
| i = np.int64(row[c]) - base | ||
| if i < 0: | ||
| below += 1 | ||
| elif i < window: | ||
| counts[i] += 1 | ||
| vmin = min(vmin, i) | ||
| # Walk the bins in value order, keeping a running count of values seen. The value at rank r is the | ||
| # first bin where that count exceeds r. If more than r_lo values lie below the window, the middle | ||
| # values are below it too and the scan is skipped; if the scan ends without reaching r_hi, they are above. | ||
| lo = -1 | ||
| hi = -1 | ||
| if below <= r_lo: | ||
| cum = below | ||
| for i in range(vmin, window): | ||
| cum += counts[i] | ||
| if lo < 0 and cum > r_lo: | ||
| lo = i | ||
| if cum > r_hi: | ||
| hi = i | ||
| break | ||
| for c in range(C): # clear only the bins this row touched, which is cheaper than zeroing the window | ||
| i = np.int64(row[c]) - base | ||
| if 0 <= i < window: | ||
| counts[i] = 0 | ||
| if hi >= 0: | ||
| out[t] = 0.5 * (np.float64(lo + base) + np.float64(hi + base)) | ||
| center = lo + base | ||
| else: | ||
| # A middle value fell outside the window, as after a large jump in the median: sort, and recentre | ||
| # the window on this row's median for the next row. | ||
| s = np.sort(row) | ||
| out[t] = 0.5 * (np.float64(s[r_lo]) + np.float64(s[r_hi])) | ||
| center = np.int64(s[r_lo]) | ||
| return out | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import numpy as np | ||
| import pytest | ||
|
|
||
| from spikeinterface.core import NumpyRecording | ||
| from spikeinterface.preprocessing import common_reference | ||
| from spikeinterface.preprocessing._row_median import HAVE_NUMBA, _INT_WINDOW, _get_int_kernel, row_median | ||
|
|
||
|
|
||
| def assert_matches_np_median(x): | ||
| median = row_median(x) | ||
| expected = np.median(x, axis=1, keepdims=True) | ||
| assert median.dtype == expected.dtype | ||
| np.testing.assert_array_equal(median, expected) | ||
|
|
||
|
|
||
| @pytest.mark.skipif(not HAVE_NUMBA, reason="integer rows fall back to np.median without numba") | ||
| @pytest.mark.parametrize("dtype", ["int16", "uint16", "int8", "uint8"]) | ||
| @pytest.mark.parametrize("num_channels", [1, 2, 3, 384]) | ||
| def test_row_median_int(dtype, num_channels): | ||
| rng = np.random.default_rng(0) | ||
| info = np.iinfo(dtype) | ||
| full_range = rng.integers(info.min, info.max, (300, num_channels), endpoint=True).astype(dtype) | ||
| ties = rng.integers(max(info.min, -3), 4, (300, num_channels)).astype(dtype) | ||
| for x in (full_range, ties, full_range[:, ::-1], full_range[::2], np.asfortranarray(full_range)): | ||
| assert_matches_np_median(x) | ||
| # row_median sends few channels to np.median, so test the kernel there directly | ||
| np.testing.assert_array_equal(_get_int_kernel()(x, _INT_WINDOW), np.median(x, axis=1)) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("dtype", [">i2", ">u2", ">f4", "float16"]) | ||
| def test_row_median_other_dtypes(dtype): | ||
| x = np.random.default_rng(0).integers(0, 200, (300, 384)).astype(dtype) | ||
| assert_matches_np_median(x) | ||
|
|
||
|
|
||
| @pytest.mark.skipif(not HAVE_NUMBA, reason="integer rows fall back to np.median without numba") | ||
| def test_row_median_int_outside_window(): | ||
| rng = np.random.default_rng(0) | ||
| x = rng.normal(0, 10, (1000, 384)).astype("int16") | ||
| x[:, 10] = np.iinfo("int16").max # a saturated channel | ||
| x[:, 11] = np.iinfo("int16").min | ||
| x[500:] += 5000 # the median jumps beyond the histogram window | ||
| assert_matches_np_median(x) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("dtype", ["float32", "float64"]) | ||
| @pytest.mark.parametrize("num_channels", [1, 2, 3, 4, 64, 384, 385]) | ||
| def test_row_median_float(dtype, num_channels): | ||
| rng = np.random.default_rng(0) | ||
| for x in ( | ||
| rng.standard_normal((300, num_channels)), | ||
| rng.integers(-3, 4, (300, num_channels)), | ||
| np.zeros((300, num_channels)), | ||
| ): | ||
| x = x.astype(dtype) | ||
| assert_matches_np_median(x) | ||
| assert_matches_np_median(np.asfortranarray(x)) | ||
| assert_matches_np_median(x[::2, ::-1]) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("dtype", ["float32", "float64"]) | ||
| def test_row_median_float_non_finite(dtype): | ||
| x = np.random.default_rng(0).standard_normal((6, 384)).astype(dtype) | ||
| x[0, 5] = np.nan | ||
| x[1, 7] = np.inf | ||
| x[2, 3] = -np.inf | ||
| x[3, [1, 2]] = [np.inf, -np.inf] | ||
| x[4] = np.finfo(dtype).max # the sum of the middle values overflows | ||
| x[5, [0, 1]] = [np.nan, np.inf] | ||
| with np.errstate(over="ignore", invalid="ignore"): | ||
| assert_matches_np_median(x) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("dtype", ["int16", "float32"]) | ||
| def test_common_reference_median_matches_np_median(dtype): | ||
| x = np.random.default_rng(0).normal(0, 20, (3000, 64)).astype(dtype) | ||
| recording = NumpyRecording(x, 30000.0) | ||
| expected = x - np.median(x, axis=1, keepdims=True) | ||
| if dtype == "int16": | ||
| expected = np.round(expected) | ||
| np.testing.assert_array_equal(common_reference(recording).get_traces(), expected.astype(dtype)) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These functions look very hard to understand and maintain. There's an alternative CMR PR open at the moment (#4836) , which looks simpler. It hasn't been jit-ed, but looks like it could be.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The open pr is another sorting approach to finding the median, which will have a lot of branches. The histogram approach for integers and counting approach for float should have fewer branches than any sorting approach, and therefore be faster on modern hardware. I can try to JIT the other approach and directly compare if you’d like. My guess is it would still be slower. Happy to add comments to the code here if ease of understanding the code is a problem.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the pointer to #4836. Its approach is simpler, and for float32 it's also faster than my kernel was (29 against 39 ms), so this PR now sorts float rows the way #4836 does and the float kernel is gone (beec896). The integer kernel that remains has comments on each step (ff73ac5).