Skip to content

CPU hygiene wins from VectorWarp: deque copies, JSON, ambiguity, CFAR - #61

Closed
Purple10101 wants to merge 4 commits into
20260914-clutter-fft-paddingfrom
20260914-vectorwarp-cpu-wins
Closed

Purple10101 wants to merge 4 commits into
20260914-clutter-fft-paddingfrom
20260914-vectorwarp-cpu-wins

Conversation

@Purple10101

Copy link
Copy Markdown

Stacked on #60. Review that first; this PR shows only its own four commits.

Four ordinary C++ hygiene changes, no algorithm changes, re-derived from the upstream fork mickeyslaven/blah2-VectorWarp (MIT, 30hours copyright intact) and re-measured on our hardware and geometry.

Live A-B-A against v0.4.3, 60 s per run. Cumulative with #60, since this is stacked:

stage owl-ded9 (2 GB) fairforest-b (4 GB)
spectrum 100.9 -> 68.5 (1.47x) 79.0 -> 57.0 (1.38x)
clutter_filter 755.5 -> 461.3 (1.64x) 615.9 -> 377.1 (1.63x)
ambiguity 238.7 -> 240.4 (0.99x) 195.8 -> 199.9 (0.98x)
detector 29.8 -> 3.3 (9.03x) 19.2 -> 2.1 (9.00x)
output_radar_data 35.3 -> 22.0 (1.61x) 23.4 -> 14.9 (1.57x)
cpi 1197.7 -> 828.4 (1.45x) 972.8 -> 688.4 (1.41x)

Welch t -38.4 and -44.5; drift on identical code +9.2 and +1.6 ms.

The four changes, isolated

change before after saving
3x whole-CPI deque copy 32.85 ms 0 32.85 ms
JSON to_json + delay_bin_to_km 34.12 ms 19.21 ms 14.91 ms
CFAR per-cell loop 34.97 ms 3.71 ms 31.26 ms
ambiguity staging + column copies 2.30 ms 0.76 ms 1.54 ms
  1. get_data() returned the sample deque by value, three times per CPI. At a million samples that is 16 MB and ~31,000 allocations each, so ~48 MB and ~94,000 allocations a CPI to read data nothing then mutates. Now a const reference. The spectrum stage also drops a full-length fftshift vector built by push_back with no reserve.
  2. The map was serialised twice and parsed once to convert one axis to km: to_json() built a DOM over 123,711 doubles, then delay_bin_to_km() re-parsed all 665 KB, rewrote 411 values and serialised again. Now written once.
  3. Ambiguity staged 823 values so a window could be read back out of them, and copied each 301-deep column four times over to transform it once.
  4. CFAR heap-allocated a training-index vector and called pow() per cell, 123,711 times a CPI, and computed 10*log10() for every cell when only detections need it. The window depends on delay, not Doppler, so it is now a table built once per CPI.

Verification

Each change is pinned by a test comparing against the code it replaces, not a snapshot. All built and run on a Pi 5.

  • JSON: byte-identical to the old pair across 8 cases including the shipped geometry. The old path is kept deliberately as the oracle; deleting it deletes the proof.
  • Ambiguity: index-for-index equivalent across 12 geometries.
  • CFAR: bit-identical in count and in every delay, Doppler and SNR value to an oracle that is a literal transcription of the original loop.
  • Deque copies: output-neutral by construction, plus the existing testClutterFft.

Also fixes a second latent out-of-bounds: the ambiguity staging array was overrun for any delayMin >= 2. Shipped -10 unaffected.

This changes detection output

The CFAR left training loop used k > 0 while the right used k >= 0, so delay bin 0 was excluded on one side and included on the other. That asymmetry was a bug and is corrected here.

On synthetic maps the count moves by at most one detection per map, always dropping a marginal one at the low-delay edge. The test reports the hoist and the edge fix separately so they are never confused. This has not been validated against real signal and a node with a real illuminator under traffic is the right place to do it.

Honest notes

  • Ambiguity is a dud. 1.5 ms isolated, and end-to-end it measured +1.7 and +4.1 ms, inside the noise. It is worth keeping for the out-of-bounds fix, not for speed.
  • Investigated and rejected: removing the ambiguity pop_front drain saves only ~1.9 ms per buffer because push_back evicts when full, so the cost moves to the next extract. Replacing the handoff with clear() + insert() measures 16.69 ms against the current 11.19 ms, so the obvious optimisation is a regression.
  • Unrelated and not fixed: IqData's min, max and mean are never assigned anywhere and are serialised every CPI.

🤖 Generated with Claude Code

Purple10101 and others added 4 commits September 14, 2026 12:59
IqData::get_data() returns the deque by value. It was called three times a CPI,
once in SpectrumAnalyser and twice in WienerHopf. At a million samples that is
16 MB and roughly 31,000 deque-chunk allocations each, so about 48 MB and
94,000 allocations a CPI to read data nothing then mutates.

view_data() returns a const reference instead. Measured on a Pi 5, one copy of
a 1,000,000-sample deque<complex<double>> is 10.95 ms, so the three cost
32.85 ms a CPI. Reading the same samples through the reference costs 3.10 ms,
which is work that happens either way.

SpectrumAnalyser also built a full-length fftshift vector with push_back and no
reserve, then read every decimation-th element out of it. The shift is now
applied in the decimation loop, which touches exactly the same elements, and
the output vector is reserved. Same values, one fewer million-element vector.

Deliberately not touched: the normalisation and the frequency axis in the same
function. The frequency loop is a known latent bug (i is unsigned, so
i = -nSpectrum/2 wraps and the loop never runs, leaving the axis empty) but
fixing it changes what the front end displays and deserves its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
to_json() built a RapidJSON DOM over the whole map, 301 x 411 = 123,711
doubles at the shipped geometry, and serialised it. delay_bin_to_km() then
parsed all 665 KB of that back into a second DOM, rewrote 411 delay values and
serialised the lot again. The map went through the writer twice and the parser
once so that one axis could be converted to km.

to_json_km() writes it once, straight to the buffer, with no DOM at either end
and the delay axis already converted. Measured on a Pi 5 at the shipped
geometry: 34.12 ms -> 19.21 ms, a saving of 14.91 ms a CPI. For reference
owl-ded9 reports output_radar_data at 35.5 ms, so the bench matches the node.

Key order and SetMaxDecimalPlaces(2) are unchanged, so the bytes are identical
and nothing downstream can tell the difference. testJsonKm asserts exactly
that against the old pair across eight cases, including the shipped geometry,
both fleet Doppler spans, an empty detection list and values chosen to stress
the decimal cap. The old pair is kept solely as that oracle: deleting it would
delete the proof.

Not taken from upstream: their 1e-30 log clamp and their dropped save.map
bound, both of which change behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three avoidable copies in Ambiguity::process:

The range loop staged 2*nDelayBins+1 values into dataCorr so that a window of
nDelayBins could be read straight back out, then copied that window into a
vector and handed it to set_row. The window is just dataZi at (j + delayMin),
wrapped, so it now goes directly into the map.

The Doppler loop called get_col(), which builds and returns a fresh vector per
delay bin, then set_col(), which took another by value. A 301-deep column was
copied four times over to be transformed once. Both ends now index map.data.

set_row and set_col took their vector by value, copying it before the
element-wise loop even started. They take a const reference now, which helps
every caller.

Measured on a Pi 5 at the shipped geometry this is worth 2.30 ms -> 0.76 ms,
so 1.54 ms a CPI. That is well short of the 15.7 ms the upstream fork reports
for this stage, and the remaining gap is not in these copies. Nor is it in the
pop_front drain further up: removing that saves only about 1.9 ms per buffer,
because IqData::push_back evicts when full, so the cost moves to the next
extract rather than disappearing. Replacing the handoff with clear() + insert()
measures 16.69 ms against the current 11.19 ms, so that apparently obvious
optimisation is a regression. None of it is pursued here.

Fixes a latent out-of-bounds read as a byproduct. dataCorr was
2*nDelayBins+1 long and the window started at nDelayBins + delayMin, so any
delayMin >= 2 read past the end of it. The shipped -10 was unaffected. The
rewrite has no staging array, so the bound cannot be exceeded, and
testAmbiguityIndexing pins both that and index-for-index equivalence with the
old arithmetic across both fleet Doppler spans.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per cell, and there are 123,711 of them a CPI, the detector heap-allocated a
std::vector<int> of training indices and called pow(pfa, -1.0/nCells). Per row
it copied the row out of the map with get_row() and computed 10*log10() for
every cell when only a detection needs it.

The training window and the false-alarm factor depend on the delay bin, not on
Doppler, so they are identical for every row. They are now built once a CPI
into a table of nDelayBins entries. The training cells are summed by index
range instead of through a vector of indices, still left then right in the
original order, since a rolling or prefix sum rounds differently and would move
the threshold. The row is read in place and the log is computed only on a
detection.

Measured on a Pi 5 at the shipped geometry: 34.97 ms -> 3.71 ms, a saving of
31.26 ms a CPI and a 9.4x on the stage. The upstream fork reports 36.4 -> 3.5
on their Pi 4, so this lands where they did.

CHANGES DETECTION OUTPUT. The left training loop used k > 0 while the right
used k >= 0, so bin 0 was excluded from the left window and included on the
right. That asymmetry was a bug and it is corrected here to k >= 0. On
synthetic maps with planted targets the count moves by at most one detection,
always dropping a marginal one at the low-delay edge where the window was
short. Real signal still needs a before and after on a node.

testCfarHoist separates the two concerns. It asserts the hoisted detector is
bit-identical, in count and in every delay, Doppler and SNR value, to an
oracle that is a literal transcription of the original loop with the edge fix
applied, so the performance rewrite is proven faithful on its own. It then runs
the same oracle both ways to report what the edge fix alone moved.

A cell whose window is empty is skipped rather than computed. The original
produced a NaN threshold there, which no cell ever exceeded, so this is the
same outcome without the arithmetic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Purple10101

Copy link
Copy Markdown
Author

Closing for now. The work is not lost: the branch stays on the remote and the measurements are recorded in the PR description above. Reopen or re-raise when there is a decision on how to take this forward.

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