Repository navigation
Conversation
…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.
|
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 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, The profile before the change also matches yours closely: On the output not being bit-identical, two checks beyond the golden vectors:
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. |
- 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
go build, go vet and go test ./... pass.
These two commits speed up the MLow encoder's FFT with no change to the codec output — the
mlowtest suite, golden vectors included, passes unchanged.mlow/fft.gowas untouched from upstream, so there is nothing downstream-specific here.Profiling one 60 ms frame through
MlowEncoder.Encodeon 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
fftReccalledmath.Cos/math.Sinonce per butterfly, on an angle it rebuilt fromk,q,neach time —math.cosalone was 28.8% of the whole encode,math.sinanother 9.1%, andfftRecwith its children 52%. Every twiddle the mixed-radix recursion needs at a sub-lengthm | nis also an n-th root of unity, so one table ofnentries 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 async.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
float32before the cosine. Against afloat64reference 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 withinfloat32noise.rfft512rfft5762. Reuse the FFT scratch buffers
With the trig gone, the GC work the FFT feeds became visible:
fftRecTwdid amake([]cpx, n)at every recursion level and eachrfftallocated 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 async.Pooland threads the arena through the recursion. Siblings run sequentially, so the arena is reused down each path; thelen(scratch) >= 2ninvariant holds because2·(n/p) <= nforp >= 2. The pool holds*fftScratch(not the slices), so nothing extra escapes onPut.rfft512rfft576Both changes are concurrency-safe (
sync.Map+sync.Pool,go test -raceclean).