Skip to content

perf(mlow): precompute FFT twiddles and reuse the recursion buffer - #38

Closed
luanluciano93 wants to merge 1 commit into
purpshell:mainfrom
luanluciano93:perf/fft-twiddle-table
Closed

luanluciano93 wants to merge 1 commit into
purpshell:mainfrom
luanluciano93:perf/fft-twiddle-table

Conversation

@luanluciano93

Copy link
Copy Markdown

fftRec calls math.Cos/math.Sin once per (k,j) pair in the naive DFT leg and once per (k,q) pair in the mixed-radix leg, even though a transform of length n only has n distinct roots of unity. It also allocates a fresh make([]cpx, n) at every node of the recursion tree.

I found this while profiling a WhatsApp call bridge built on meowcaller. In a CPU profile of one 60 ms frame through MlowEncoder:

% of total CPU
math.cos 27.4%
math.sin 11.4%
mlow.fftRec (cumulative) 46.7%

So roughly 39% of the entire media path was spent recomputing values that never change.

The change

  • cache the roots of unity per (size, sign) in a sync.Map, indexed by (k*j) mod n;
  • carve the recursion scratch out of one pooled buffer instead of allocating per node.

Indexing by the remainder is also slightly more accurate than the previous sign*2*pi*k*j/n float32 product, whose error grew with k*j.

Numbers

AMD EPYC 9354P, go1.26, -benchtime 200x -count=5, medians:

BenchmarkMlowEncodeFrame   7.18ms -> 2.76ms   (2.6x)
BenchmarkPercFFT            267us ->   53us   (5.0x)
allocs/frame (encoder)      3855  ->  1966
allocs/op (rfft)             129  ->     3

After the change math.cos drops to 2.4% and fftRec to 21.9% cumulative; what is left at the top is real codec work (addPulse, celpDotProd, SmplPitch).

Correctness

The more accurate twiddles flip a few quantiser decisions, so the bitstream changes very slightly — 200 encoded speech frames came to 34,161 bytes instead of 34,181. Everything I could check says the output is still valid:

  • the existing mlow tests pass unmodified;
  • an unpatched MlowDecoder reads back all 200 frames produced by the patched encoder: no empty frames, no NaNs, every frame carrying energy;
  • three real WhatsApp calls placed with this patch carried audio in both directions, and the far end heard the transmitted tones cleanly — indistinguishable from the same test on unpatched code.

If you would rather keep the bitstream bit-identical, this cannot be done with a table at all, since the old code's angle is a float32 product rather than a value with period n. I would be happy to put the table behind a build tag or an option instead, if you prefer.

Benchmarks

I added mlow/fft_bench_test.go at both levels so the change can be re-measured. The encoder benchmark synthesises voice-like audio on purpose: on silence the VAD takes a much shorter path and the numbers flatter the encoder.

fftRec called math.Cos/math.Sin once per (k,j) pair in the naive DFT leg and
once per (k,q) pair in the mixed-radix leg, even though a transform of length n
only has n distinct roots of unity. In a CPU profile of a 60 ms frame through
MlowEncoder, math.cos and math.sin together accounted for ~39% of the whole
media path, with fftRec at ~47% cumulative.

It also allocated a fresh make([]cpx, n) at every node of the recursion tree:
~3,800 allocations per 60 ms frame, or roughly 28 MB/s of garbage per concurrent
call.

This caches the roots per (size, sign) in a sync.Map and indexes them by
(k*j) mod n, and carves the recursion scratch out of a single pooled buffer.
Indexing by the remainder is also more accurate than the previous float32 angle
product, whose error grew with k*j.

Measured on an AMD EPYC 9354P, go1.26, -benchtime 200x -count=5, medians:

    BenchmarkMlowEncodeFrame   7.18ms -> 2.76ms   (2.6x)
    BenchmarkPercFFT            267us ->   53us   (5.0x)
    allocs/frame (encoder)      3855  ->  1966
    allocs/op (rfft)             129  ->     3

The bitstream changes very slightly, as the more accurate twiddles flip a few
quantiser decisions: 200 encoded speech frames came to 34,161 bytes instead of
34,181. The output stays valid — the existing mlow tests pass, MlowDecoder reads
all 200 frames back with no empty frames and no NaNs, and three real WhatsApp
calls placed with this patch carried audio in both directions, with the far end
hearing the transmitted tones cleanly.

Benchmarks are added for both levels so the change can be re-measured. The
encoder benchmark feeds voice-like audio on purpose: on silence the VAD takes a
much shorter path and the numbers flatter the encoder.
@luanluciano93

Copy link
Copy Markdown
Author

Closing as a duplicate of #30, which predates this by a month and is the better change — it shares one twiddle table across all recursion levels, also removes the rfft allocations in perc.go, and quantifies the accuracy improvement against a float64 reference DFT.

I have moved my reproduction and the real-call validation of the bitstream change into a comment on #30.

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.

1 participant