@@ -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
0 commit comments