Skip to content

mlow: speed up the encoder FFT (precomputed twiddles + pooled scratch) - #30

Open
lomeliDev wants to merge 2 commits into
purpshell:mainfrom
lomeliDev:upstream-mlow-fft-perf
Open

lomeliDev wants to merge 2 commits into
purpshell:mainfrom
lomeliDev:upstream-mlow-fft-perf

Conversation

@lomeliDev

Copy link
Copy Markdown

These two commits speed up the MLow encoder's FFT with no change to the codec output — the mlow test suite, golden vectors included, passes unchanged. mlow/fft.go was untouched from upstream, so there is nothing downstream-specific here.

Profiling one 60 ms frame through MlowEncoder.Encode on an EPYC 7742 showed the FFT dominating the encode, and the encoder is ~99% of the cost of carrying a call.

1. Precompute the twiddle factors

fftRec called math.Cos/math.Sin once per butterfly, on an angle it rebuilt from k, q, n each time — math.cos alone was 28.8% of the whole encode, math.sin another 9.1%, and fftRec with its children 52%. Every twiddle the mixed-radix recursion needs at a sub-length m | n is also an n-th root of unity, so one table of n entries serves every level: e^(sign·i·2π·k·q/m) == w[(k·q mod m)·(n/m)]. The table is built once per (length, direction) and memoized in a sync.Map; the codec only ever asks for 512 and 576, so the cache holds a couple of entries for the life of the process and never grows.

Output is not bit-identical, and that is an improvement: the angle is now exact instead of being rounded to float32 before the cosine. Against a float64 reference DFT the peak relative error drops — 1.12e-7 → 9.5e-8 at n=512, 1.71e-7 → 1.13e-7 at n=576 — all within float32 noise.

Before After
frame through the encoder 6.49 ms 3.76 ms 1.72×
rfft 512 266 µs 87 µs 3.05×
rfft 576 289 µs 92 µs 3.14×

2. Reuse the FFT scratch buffers

With the trig gone, the GC work the FFT feeds became visible: fftRecTw did a make([]cpx, n) at every recursion level and each rfft allocated two more per transform — ~3700 allocations and 1.6 MB per 60 ms frame. This pools a per-transform scratch (two n-sized complex buffers plus a 2n recursion arena) in a sync.Pool and threads the arena through the recursion. Siblings run sequentially, so the arena is reused down each path; the len(scratch) >= 2n invariant holds because 2·(n/p) <= n for p >= 2. The pool holds *fftScratch (not the slices), so nothing extra escapes on Put.

Before After
frame through the encoder 3.73 ms 3.24 ms 1.15×
allocations / frame 3720 1788 −52%
rfft 512 85 µs 62 µs 1.37×, zero-alloc
rfft 576 92 µs 66 µs 1.39×, zero-alloc

Both changes are concurrency-safe (sync.Map + sync.Pool, go test -race clean).

…per butterfly

fftRec called math.Cos and math.Sin once per butterfly, on an angle it rebuilt
from k, q and n every time. Profiling one 60 ms frame through the encoder puts
math.cos at 28.8% of the whole encode and math.sin at another 9.1%; fftRec and
its children are 52%. The encoder in turn is ~99% of the cost of carrying a
call, so better than a third of a voice call was spent recomputing the same
few hundred roots of unity.

Every twiddle the mixed-radix recursion needs at a sub-length m that divides n
is also an n-th root of unity, so a single table of n entries serves every
level: e^(sign*i*2*pi*k*q/m) == w[(k*q mod m)*(n/m)]. The table is built once
per (length, direction) and memoized; the codec only ever asks for 512 (LPC
analysis) and 576 (perceptual model), so the cache holds a couple of entries
for the life of the process and never grows with traffic.

Measured on an EPYC 7742, one 60 ms frame through MlowEncoder.Encode:

  encode frame   6.49 ms -> 3.76 ms   (1.72x)
  rfft 512        266 us ->   87 us   (3.05x)
  rfft 576        289 us ->   92 us   (3.14x)

The output is not bit-identical: the angle is now exact instead of being
rounded to float32 before the cosine, so the transform is *more* accurate, not
less. Against a float64 reference DFT the peak relative error drops from
1.12e-7 to 9.5e-8 at n=512 and from 1.71e-7 to 1.13e-7 at n=576 — both inside
float32 noise. The whole mlow test suite, golden vectors included, passes
unchanged.

fft_test.go pins the transform to that reference DFT at the lengths the codec
uses plus two small mixed-radix ones that hit the p == n base case, checks the
inverse table is the conjugate of the forward one, and carries the encoder
benchmark that the sizing work re-runs after each optimization.
fftRecTw did a make([]cpx, n) at every recursion level and each rfft
allocated two more []cpx per transform: ~3700 allocations and 1.6 MB per
60 ms frame through the encoder. The GC work that feeds is now visible in
the profile after the twiddle-table patch removed the trig cost.

Pool a per-transform scratch (two n-sized complex buffers plus a 2n
recursion arena) keyed off sync.Pool, and thread the arena through the
recursion. Siblings run sequentially so the arena is reused down each
path; the len(scratch) >= 2n invariant holds because 2*(n/p) <= n for
p >= 2. The codec only ever asks for lengths 512 and 576, so the pool
settles at a couple of buffers and never grows with traffic.

Output is bit-identical (golden vectors unchanged). On the EPYC 7742:
BenchmarkEncodeFrame 3.73ms -> 3.24ms (~1.15x), 3720 -> 1788 allocs;
rfft 512 85us -> 62us and rfft 576 92us -> 66us, both now zero-alloc.
@luanluciano93

Copy link
Copy Markdown

I arrived at the same two changes independently while profiling a call bridge, opened #38 before finding this PR, and have now closed it — yours is older and goes further (one table shared across recursion levels, the perc.go allocations, and the float64 reference-DFT error comparison).

Posting what I have that might help this move: independent reproduction, plus validation of the bitstream change against real WhatsApp calls.

Reproduction on an EPYC 9354P, go1.26, -benchtime 200x -count=5, medians — same shape as your EPYC 7742 numbers:

frame through MlowEncoder   7.18ms -> 2.76ms   (2.6x)
rfft (perc size)             267us ->   53us   (5.0x)
allocs / frame               3855  ->  1966

The profile before the change also matches yours closely: math.cos 27.4%, math.sin 11.4%, fftRec 46.7% cumulative. After it, math.cos is 2.4% and fftRec 21.9%; what is left on top is addPulse, celpDotProd and SmplPitch.

On the output not being bit-identical, two checks beyond the golden vectors:

  1. an unpatched MlowDecoder reads back all 200 frames produced by a patched encoder — no empty frames, no NaNs, every frame carrying energy;
  2. three real WhatsApp calls on a linked device, answered with Answer(), one of them encoded with the patch: the far end (an Android phone) heard the transmitted tones cleanly, and inbound audio came back intelligible in all of them. The patched call was indistinguishable from the unpatched ones.

So as far as I can tell the changed quantiser decisions are accepted by real WhatsApp decoders, not just by ours. Happy to run anything else that would help review this.

suhwr added a commit to suhwr/meowcaller that referenced this pull request Sep 30, 2026
- merge upstream PR purpshell#26 for inbound call reliability
- merge upstream PR purpshell#30 for MLow FFT performance
- update internal imports to github.com/suhwr/meowcaller
Hellysonrp added a commit to Hellysonrp/meowcaller that referenced this pull request Oct 7, 2026
go build, go vet and go test ./... pass.
viktorvillalobos added a commit to wuapidev/meowcaller that referenced this pull request Oct 11, 2026
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.

2 participants