Skip to content

ofdm: FFT-based burst acquisition — 2.2x less idle RX CPU, identical sensitivity (#162) - #163

Merged
rafael2k merged 2 commits into
mercuryv2from
acq-fft-correlation
Aug 10, 2026
Merged

ofdm: FFT-based burst acquisition — 2.2x less idle RX CPU, identical sensitivity (#162)#163
rafael2k merged 2 commits into
mercuryv2from
acq-fft-correlation

Conversation

@rafael2k

@rafael2k rafael2k commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

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:

 90.08%  3.72s  6,172,320 calls   ofdm_complex_dot_product
  6.78%  0.28s      2,640 calls   est_timing_and_freq

~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

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, 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 make fstep an exact number of bins (or the rotation isn't 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. 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

trunk this PR
DATAC16 idle 6.61 % 3.18 %
DATAC3 idle 6.81 % 2.88 %
dual decoder (#162's config) 13.42 % 6.02 %

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_decode output 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_acq is the permanent guard. It runs the real modem over identical audio with each path (new freedv_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

  • 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. Fixed.
  • The prototype that justified this work measured 7.9×, against a hand-written brute-force loop ~3.8× slower than the real one. The honest in-situ figure is 2.2×.

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 windows cross-build, aarch64 build. This touches vendored codec2 and adds a kiss_fft dependency to ofdm.c, so the cross-builds were run explicitly rather than assumed.

🤖 Generated with Claude Code

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
rafael2k force-pushed the acq-fft-correlation branch from a9effec to 1b383ae Compare August 10, 2026 22:59
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.
@rafael2k
rafael2k merged commit 8216bb8 into mercuryv2 Aug 10, 2026
8 checks passed
@pedromessetti
pedromessetti deleted the acq-fft-correlation branch August 11, 2026 13:10
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