Skip to content

Commit 4490d6e

Browse files
authored
Merge pull request #216 from ezmsg-org/perf/ewma-hoist-filter-axis
EWMA: hoist the filter axis to last before scipy's IIR loop
2 parents 073b01e + 21d7e1a commit 4490d6e

2 files changed

Lines changed: 86 additions & 14 deletions

File tree

src/ezmsg/sigproc/ewma.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,49 @@ def _reset_state(self, message: AxisArray) -> None:
257257
self._state.zi = xp.zeros_like(sub_dat)
258258
self._state.n_seen = 0
259259

260+
def _lfilter_axis_last(
261+
self, data: npt.NDArray, axis_idx: int, zi: npt.NDArray | None
262+
) -> tuple[npt.NDArray, npt.NDArray]:
263+
"""Run the EWMA recurrence with the filter axis contiguous.
264+
265+
scipy's IIR loop strides by the trailing dimension whenever the filter
266+
axis is not last, and the cost grows *superlinearly* with that dimension:
267+
at 300 samples the step from 256 to 1024 channels is 4x the data but 8.7x
268+
the time, versus linear when the axis is already last. Acquisition sources
269+
emit ``(time, ch)``, so the streaming case hits the bad orientation by
270+
default.
271+
272+
Hoisting the axis to the end and moving the result back costs two copies
273+
that pay for themselves at every size measured, and never lose:
274+
1.04x at 300x256, 1.78x at 30x1024, 4.41x at 300x1024, 1.72x at 3000x1024
275+
-- all *including* the copies. ``filter.py``'s SOS kernel already does
276+
this (``util/sosfilt_direct``), which is why ``ButterworthZeroPhase``
277+
measures layout-neutral while this did not.
278+
279+
``zi`` is one sample slice, so hoisting it too is negligible and keeps the
280+
stored state in the caller's layout for anything that inspects it.
281+
"""
282+
b = [self._state.alpha]
283+
a = [1.0, self._state.alpha - 1.0]
284+
last = data.ndim - 1
285+
if axis_idx == last:
286+
return sps.lfilter(b, a, data, axis=-1, zi=zi)
287+
288+
x = np.ascontiguousarray(np.moveaxis(data, axis_idx, last))
289+
zi_last = None if zi is None else np.ascontiguousarray(np.moveaxis(zi, axis_idx, last))
290+
y, zf = sps.lfilter(b, a, x, axis=-1, zi=zi_last)
291+
# Materialize back into the caller's layout rather than returning a
292+
# transposed view. The view saves a full-size pass here and is 12-17%
293+
# faster *in isolation*, but it loses end to end at the sizes that matter
294+
# (measured on the whole scaler at 1024 ch: 6% worse at 300 samples, 2%
295+
# worse at 1000, 5% better only by 3000) because every downstream op then
296+
# reads strided. Keep the copy; it is also the less surprising contract
297+
# for a library consumer.
298+
return (
299+
np.ascontiguousarray(np.moveaxis(y, last, axis_idx)),
300+
np.ascontiguousarray(np.moveaxis(zf, last, axis_idx)),
301+
)
302+
260303
def _process(self, message: AxisArray) -> AxisArray:
261304
axis = self.settings.axis or message.dims[0]
262305
axis_idx = message.get_axis_idx(axis)
@@ -276,24 +319,12 @@ def _process(self, message: AxisArray) -> AxisArray:
276319
# Normal behavior: update state with new samples.
277320
if self._state.zi is not None and not is_numpy_array(self._state.zi):
278321
self._state.zi = np.asarray(self._state.zi)
279-
expected, self._state.zi = sps.lfilter(
280-
[self._state.alpha],
281-
[1.0, self._state.alpha - 1.0],
282-
message.data,
283-
axis=axis_idx,
284-
zi=self._state.zi,
285-
)
322+
expected, self._state.zi = self._lfilter_axis_last(message.data, axis_idx, self._state.zi)
286323
else:
287324
# Process-only: compute output without updating state.
288325
if self._state.zi is not None and not is_numpy_array(self._state.zi):
289326
self._state.zi = np.asarray(self._state.zi)
290-
expected, _ = sps.lfilter(
291-
[self._state.alpha],
292-
[1.0, self._state.alpha - 1.0],
293-
message.data,
294-
axis=axis_idx,
295-
zi=self._state.zi,
296-
)
327+
expected, _ = self._lfilter_axis_last(message.data, axis_idx, self._state.zi)
297328

298329
# The zero-initialized EWMA under-counts by 1-(1-alpha)^t at cumulative
299330
# sample t; dividing it out gives the exact exponentially-weighted

tests/unit/test_ewma.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,3 +530,44 @@ def mk(d, offset):
530530
parts.append(chunked(mk(c, n)).data)
531531
n += c.shape[0]
532532
np.testing.assert_allclose(np.concatenate(parts, axis=0), single, rtol=1e-10, atol=1e-10)
533+
534+
535+
@pytest.mark.parametrize("n_dim", [2, 3])
536+
def test_ewma_result_is_independent_of_filter_axis_position(n_dim):
537+
"""Hoisting the filter axis to last must not change the result.
538+
539+
``_process`` moves the filter axis to the end before handing scipy the
540+
recurrence, because scipy's IIR loop strides by the trailing dimension
541+
otherwise and degrades superlinearly with it. That is purely a memory-layout
542+
optimization, so filtering axis 0 of ``(time, ch)`` and axis -1 of the
543+
transposed array must agree exactly -- and keep agreeing across chunks, since
544+
``zi`` rides through the same hoist.
545+
"""
546+
fs = 1000.0
547+
n_times, n_ch = 137, 24
548+
rng = np.random.default_rng(7)
549+
shape = (n_times, n_ch) if n_dim == 2 else (n_times, n_ch, 3)
550+
data = rng.standard_normal(shape).astype(np.float32)
551+
dims = ["time", "ch"] if n_dim == 2 else ["time", "ch", "feat"]
552+
553+
def run(transpose: bool, chunks: list[int]) -> np.ndarray:
554+
proc = EWMATransformer(time_constant=0.5, axis="time", accumulate=True)
555+
outs, start = [], 0
556+
for n in chunks:
557+
block = data[start : start + n]
558+
if transpose:
559+
block = np.ascontiguousarray(np.moveaxis(block, 0, -1))
560+
msg = AxisArray(
561+
data=block,
562+
dims=(dims[1:] + ["time"]) if transpose else dims,
563+
axes={"time": AxisArray.TimeAxis(fs=fs, offset=start / fs)},
564+
)
565+
out = proc(msg)
566+
arr = out.data
567+
outs.append(np.moveaxis(arr, -1, 0) if transpose else arr)
568+
start += n
569+
return np.concatenate(outs, axis=0)
570+
571+
# Ragged chunks so the hoisted `zi` has to carry correctly across boundaries.
572+
chunks = [1, 13, 40, 3, 80]
573+
np.testing.assert_array_equal(run(False, chunks), run(True, chunks))

0 commit comments

Comments
 (0)