ofdm: FFT-based burst acquisition — 2.2x less idle RX CPU, identical sensitivity (#162) - #163
Merged
Conversation
rafael2k
force-pushed
the
acq-fft-correlation
branch
from
August 10, 2026 21:11
f1a24b8 to
a9effec
Compare
Issue #162 reported 100 % CPU on a Pi 4 with the RX backlog appearing before any client connected or any session existed. Profiling the idle path put 90 % of all RX CPU in one function: ofdm_complex_dot_product, ~514,000 calls per second of audio, from est_timing_and_freq()'s brute-force search over every (frequency hypothesis, timing offset) pair. Every mode measured the same ~6.6 % of a core, because the cost is the search grid and the preamble, not the mode's bandwidth or code. The inner expression is a cross-correlation, so per hypothesis c_w = IFFT( FFT(rx) . conj(FFT(g_w)) ), g_w[i] = known[i] * e^{jwi} and multiplying by e^{jwi} in time is a rotation in frequency, so FFT(g_w) is FFT(known) rotated -- one template transform serves all 21 hypotheses. The transform length is the whole trick and is not free to choose. It must be >= Nrx so no correlation lag wraps onto real data, AND must make fstep an exact number of bins or the rotation is not a rotation. fs/fstep = 1600 but Nrx = 1760, so the smallest workable length is 3200, where 5 Hz is exactly two bins. Checked across every mode in ofdm_mode.c: all land on 1600 or 3200, both factoring into 2s and 5s, comfortable for kiss_fft's mixed radix. Where no suitable length exists the code returns false and the brute-force loop runs -- which is what the fine stage does, its 1 Hz step would want an 8000-point transform to save 35 dot products. CPU, idle decoder on noise (utils/rxcost_bench): a single decoder 6.61 % -> 3.18 % of one x86 core; the dual decoder issue #162 runs, DATAC16 control plus DATAC3 payload, 13.42 % -> 6.02 %. Scaled to a Pi 4 core that is roughly 67-134 % down to 30-60 %. Sensitivity is the constraint, and it does not move. Against a build of trunk (57a4543), utils/acquire_vs_decode output is IDENTICAL -- same acquired and delivered counts on every row -- for DATAC16, DATAC15, DATAC13, DATAC4, DATAC3, DATAC1 and DATAC0. DATAC16 at 100 trials from -13 to -8 dB: 7/2, 26/19, 52/48, 84/78, 99/98, 100/99, matching trunk exactly rather than within noise. test_ofdm_acq is the permanent guard: it runs the real modem over identical audio with each path via the new freedv_set_acq_fft_enable(), across four modes and four SNRs down to -13 dB, and requires the outcomes to agree -- including on the bursts that fail, since agreeing only where the signal is strong would prove nothing. It detects a one-bin (2.5 Hz) error in the frequency rotation, and detects it at -11 dB rather than -4 dB, which is the right signature: high SNR absorbs a sloppy coarse estimate, the fringe does not. Two traps worth recording. The first mutation runs all "passed" and the test looked worthless -- the tests/Makefile rule listed libfreedvdata.a only in the link command, not as a prerequisite, so make never relinked and every run tested the previous build. The rule now names it on the left-hand side. And the prototype that justified this work measured 7.9x, against a hand-written brute-force loop ~3.8x slower than the real one; the honest in-situ figure is 2.2x on the dual decoder. Still on the table: a full 3200-point IFFT is computed per hypothesis and only 220 of 3200 outputs are used (tstep=4). Folding the spectrum into N/4 bins before an 800-point IFFT is exact and should cut the remaining dominant cost about 4x again. Left for a separate change with its own equivalence run. Gate: unit suite, integration (245 s), full Windows cross-build, aarch64. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rafael2k
force-pushed
the
acq-fft-correlation
branch
from
August 10, 2026 22:59
a9effec to
1b383ae
Compare
test_ofdm_acq lists ../modem/freedv/libfreedvdata.a as a prerequisite so make
relinks when the library changes -- without that, the guard silently tests the
previous build, which is how an earlier mutation check appeared to pass.
But tests/Makefile had no rule to *build* it, and CI runs `make -C tests test`
on a clean tree without building the main project first:
make[1]: *** No rule to make target '../modem/freedv/libfreedvdata.a',
needed by 'test_ofdm_acq'
It resolved on a developer box only because the top-level make had already
produced the archive. Recurse into modem/freedv to build it, kept .PHONY so
the sub-make decides freshness rather than a stale timestamp here.
Verified by deleting the archive and building tests alone, which is what CI
does.
This was referenced Aug 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses the CPU half of #162. The connect half is already fixed on trunk by #161.
The problem, measured
Profiling the idle RX path (gprof, noise, one decoder) put 90 % of all RX CPU in one function:
~514,000 complex dot products per second of audio, from the brute-force search over every (frequency hypothesis × timing offset) pair in the burst acquisition detector. Every mode measured the same ~6.6 % of a core — the cost is the search grid and the preamble length, not the mode's bandwidth or code size. And it runs continuously, because searching for a preamble that isn't there is its job.
The change
The inner expression is a cross-correlation, so per hypothesis
and multiplying by
e^{jwi}in time is a rotation in frequency — soFFT(g_w)isFFT(known)rotated, and one template transform serves all 21 hypotheses.The transform length is the whole trick and isn't free. It must be ≥
Nrx(or a correlation lag wraps onto real data) and must makefstepan exact number of bins (or the rotation isn't a rotation).fs/fstep = 1600butNrx = 1760, so the smallest workable length is 3200, where 5 Hz is exactly two bins. Checked across every mode inofdm_mode.c: all land on 1600 or 3200, both factoring into 2s and 5s. Where no suitable length exists the function returns false and the brute-force loop runs — which is what the fine stage does, since its 1 Hz step would want an 8000-point transform to save 35 dot products.CPU
Scaled to a Pi 4 core (~5–10× slower), that moves 67–134 % of a core to roughly 30–60 %.
Sensitivity does not move — this is the constraint
Against a build of trunk
57a4543,utils/acquire_vs_decodeoutput is IDENTICAL — same acquired and delivered counts on every row — for DATAC16, DATAC15, DATAC13, DATAC4, DATAC3, DATAC1, DATAC0.DATAC16, 100 trials, −13…−8 dB:
7/2, 26/19, 52/48, 84/78, 99/98, 100/99— matching trunk exactly, not within noise.test_ofdm_acqis the permanent guard. It runs the real modem over identical audio with each path (newfreedv_set_acq_fft_enable()), across 4 modes × 4 SNRs down to −13 dB, and requires agreement including on the bursts that fail — agreeing only where the signal is strong would prove nothing. It detects a one-bin (2.5 Hz) error in the frequency rotation, and detects it at −11 dB rather than −4 dB: high SNR absorbs a sloppy coarse estimate, the fringe does not.Two things I got wrong, recorded in the commit
tests/Makefilerule listedlibfreedvdata.aonly in the link command, not as a prerequisite, somakenever relinked and every run tested the previous build. Fixed.Still on the table
A full 3200-point IFFT is computed per hypothesis and only 220 of 3200 outputs are used (
tstep=4). Folding the spectrum into N/4 bins before an 800-point IFFT is exact and should cut the remaining dominant cost ~4× again — plausibly 15–30 % on a Pi 4. Deliberately left for a separate change with its own equivalence run.Gate
Unit suite, integration (245 s), full
make windowscross-build, aarch64 build. This touches vendored codec2 and adds akiss_fftdependency toofdm.c, so the cross-builds were run explicitly rather than assumed.🤖 Generated with Claude Code