Skip to content

EWMA: hoist the filter axis to last before scipy's IIR loop - #216

Merged
cboulay merged 1 commit into
devfrom
perf/ewma-hoist-filter-axis
Aug 10, 2026
Merged

EWMA: hoist the filter axis to last before scipy's IIR loop#216
cboulay merged 1 commit into
devfrom
perf/ewma-hoist-filter-axis

Conversation

@cboulay

@cboulay cboulay commented Aug 10, 2026

Copy link
Copy Markdown
Member

Closes #215.

EWMATransformer._process handed scipy.signal.lfilter whatever axis the message carried. When time is axis 0 of a C-contiguous (time, ch) array -- the layout every acquisition source emits -- each sample step strides n_ch * itemsize, and scipy's IIR recurrence degrades badly.

AdaptiveStandardScaler runs two EWMAs per message (one on x, one on x**2), so it paid this twice.

Change

Move the filter axis to the end, run lfilter(axis=-1), move back. zi is one sample slice so it rides through the same hoist, and the stored state stays in the caller's layout.

This is what filter.py / util/sosfilt_direct already do -- and it is why ButterworthZeroPhase measures layout-neutral (341.6 vs 342.6 ms/s in a downstream chain) despite being a 4th-order SOS cascade doing strictly more work than a 1st-order EWMA.

Correctness

Bit-identical, max|diff| = 0.0, verified against a reference that reproduces the old un-hoisted lfilter with the same streaming zi, across: both layouts, ragged chunk sequences ([7, 13, 300, 1, 1679, 1000]), single-chunk, and float64.

New test_ewma_result_is_independent_of_filter_axis_position pins that axis-0 and axis-(-1) filtering agree exactly over ragged chunks, for 2-D and 3-D messages. 4037 unit tests pass.

Measurements

AdaptiveStandardScaler at 300x1024 float32, time-major:

before after
whole transformer 7.676 ms 3.670 ms (2.09x)
layout sensitivity 2.64x 1.28x

The isolated lfilter hoist, including both copies:

shape axis=0 direct hoisted speedup
300x256 0.290 ms 0.280 ms 1.04x
300x1024 5.638 ms 1.277 ms 4.41x
3000x1024 26.66 ms 15.54 ms 1.72x
30x1024 0.222 ms 0.125 ms 1.78x

Never loses; wins large where it matters.

Downstream, on an intracortical feature chain at 1024 ch, this is 1.51x on total chain cost at 300-sample chunks. Arguably more valuable for a real-time graph: run-to-run spread drops from 15-22% to 2-5.7% -- the strided access was making the stage's cost unpredictable, not just slow.

Why it materializes instead of returning a view

Returning np.moveaxis(y, ...) as a view skips a full-size pass and is 12-17% faster in isolation -- the obvious optimization. It loses end to end, because every downstream op then reads strided:

n (1024 ch) 300 1000 3000
view 3.768 ms 12.812 ms 39.940 ms
materialized 3.548 12.501 41.867

Only wins by 3000 samples. Recorded in a code comment so it does not get "optimized" back.

Not included

Cost per second of data still rises with chunk size in the hoisted path (355 -> 375 -> 419 ms/s at 300/1000/3000) where a natively-contiguous one falls (286 -> 285 -> 276): past ~1000 samples the hoist's own copies become the limiting term. Tiling bounds them and wins clearly by 9000 samples (41.8 vs 59.0 ms), losing below ~3000. Where that threshold belongs is a design call, so this PR is untiled -- see #215 for the data. The other two items from #215 (the ~10 full-size temporaries in the scaler's arithmetic tail, and merging the two lfilter calls onto a stacked [x, x**2]) are also untouched.

`_process` handed `scipy.signal.lfilter` whatever axis the message carried.
When time is axis 0 of a C-contiguous (time, ch) array -- the layout every
acquisition source emits -- each sample step strides `n_ch * itemsize`, and
the cost grows superlinearly with the trailing dimension: at 300 samples,
going from 256 to 1024 channels is 4x the data but 8.7x the time, versus
linear when the axis is already last.

Move the axis to the end, filter, and move back. The two copies pay for
themselves at every size measured and never lose: 1.04x at 300x256, 1.78x at
30x1024, 4.41x at 300x1024, 1.72x at 3000x1024 -- all including the copies.
`filter.py`'s SOS kernel already does this via util/sosfilt_direct, which is
why ButterworthZeroPhase measures layout-neutral while this did not.

`zi` is one sample slice, so it rides through the same hoist; the stored
state stays in the caller's layout for anything that inspects it.

AdaptiveStandardScaler runs two EWMAs per message and so paid this twice.
At 300x1024 float32 it goes 7.676 -> 3.670 ms (2.09x), and its layout
sensitivity drops from 2.64x to 1.28x. On a downstream intracortical feature
chain that is 1.51x on total chain cost at 300-sample chunks; just as
usefully, run-to-run spread falls from 15-22% to 2-5.7%, because the strided
access was making the stage's cost unpredictable rather than merely slow.

Output is bit-identical (max|diff| = 0.0) across both layouts, ragged chunk
sequences, single-chunk, and float64, checked against a reference that
reproduces the old un-hoisted lfilter with the same streaming zi.

Deliberately materializes the result rather than returning a transposed view.
The view saves a full-size pass and is 12-17% faster in isolation, but loses
end to end at the sizes that matter (6% worse at 300 samples, 2% at 1000,
5% better only by 3000) because every downstream op then reads strided.

Refs #215.
@cboulay
cboulay merged commit 4490d6e into dev Aug 10, 2026
14 checks passed
@cboulay
cboulay deleted the perf/ewma-hoist-filter-axis branch August 10, 2026 22:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

EWMA hands scipy.lfilter the raw filter axis; hoisting time to the last axis is up to 4.4x faster

1 participant