Conversation
Stage 2a of docs/audit-fft-and-code-smells.md, first step: the C++20 engine that will replace the vendored Ooura C, added beside it and routed nowhere. This commit carries the banner (SPDX LicenseRef-Ooura AND MIT, Ooura's notice verbatim, the derivative-work statement), the transliteration rules that the parity gate depends on, the class surface (constructor from size, size(); the transforms land with the last kernel group), and the table builders makewt, makeipt and makect run once from the constructor with rdft's first-call protocol, so the lazy ip[0] = 0 initialization is gone. Table semantics per precision (Decision D10): the locals are typed Sample, every libm call takes an explicit static_cast<double> argument (the C's float build promotes its float argument to the double cos/sin/atan; std::cos(float) would call cosf) and the result is converted to Sample exactly where the C's assignment converted it, including 0.5 / cos(...), 0.5 / wk1r and 0.5 * cos(...) being double arithmetic converted on assignment. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
bitrv2, bitrv2conj, bitrv216, bitrv216neg, bitrv208 and bitrv208neg as private static members, statement for statement from fftsg.c. The only changes are the ones the header's rules allow: double -> Sample in the local declarations, const on the ip table pointer, and the local prototype declarations dropped (class scope resolves the names). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
cftfsub and cftbsub with everything they reach: cftf1st, cftb1st, cftrec4, cfttree, cftleaf, cftmdl1, cftmdl2, cftfx41, cftf161, cftf162, cftf081, cftf082, cftf040, cftb040 and cftx020, statement for statement from fftsg.c (the USE_CDFT_THREADS blocks in cftfsub/cftbsub and the local prototypes dropped, double -> Sample, const on the w table pointer; nothing else). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
…inted at the port rftfsub and rftbsub (the one double literal each, 0.5 - c[nc - kk], gets the explicit narrowing the C's assignment performs), and forward_inplace / inverse_inplace: rdft's body minus the table build, const and noexcept, the tables loaded into locals once per transform so the helpers receive a, ip and w by parameter as the C functions do. tests/test_fft_parity_ooura.cpp: engine_under_test re-pointed from basic_real_fft at detail::split_radix_rdft, the one place its header comment names, and that comment updated. The memcmp gate is unchanged. With the alias re-pointed and rule 2 of the header deliberately broken on one makewt line (std::cos on the float product, i.e. cosf), the three float gates fail and the three double gates pass; with the header as committed, all seven parity tests pass on g++ 13.3 and clang++ 18.1 (-ffp-contract=off gate: identity at every N from 4 to 65536 plus 2^20, both precisions, forward and inverse; the informational default-flags run reports 0 ulp on this x86-64 host, which has no FMA without -march). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
…, informational From Stage 2a until 2c the icount job reports the C and the port side by side without ratcheting the port (nothing is routed yet; audit Part 3): - bench_common.h: TAP_DSP_BENCH_ENGINE gains split_radix; the port is measured through split_radix_bench_adapter, which presents basic_real_fft's out-of-place forward()/inverse() with the same copy loop and the same 2/N arithmetic, so a port-vs-C delta is the transform's alone. backend_name() prints split_radix for the port (it has no backend behind it). - bench/icount/CMakeLists.txt: every float scenario builds twice, the second binary keyed <scenario>_port with the port; both get the host smoke test. bench/CMakeLists.txt: the float size probe likewise (rfft_f32_512_port). - scripts/icount.py: a documented INFORMATIONAL_SUFFIX (_port). Such a scenario is counted and printed after the gated ones beside its C sibling with the ratio port/C and whether the two DONE checksums agree, but it can never fail the run, --update never writes it to the baselines, a baseline naming it is reported and ignored, and --record files it under a separate top-level "informational" key that --merge skips. A pull request therefore shows the ratios in the job log with no seeding and no gate change. - bench.yml: the size step builds and prints the port probe's .text and the port/C ratio beside the C's; the ceiling still applies to the C probe only. - bench/README.md: the two _port scenarios, the mechanism and its limits (on the m55 key the sibling is CMSIS; port-vs-Ooura on the M55 is m55-ooura's). On this x86-64 host the C and port icount binaries print identical checksums (0x662dd085b5b88325 at N = 512, 0x806d1c5dbb65c525 at N = 2048) under both g++ 13.3 and clang++ 18.1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
From the CI and bench logs of #28 at 8afe6cf (2026-09-18), labelled with run and job ids and toolchains, never typed in: - fp-contraction policy, item 2: the informational default-flags max-ulp per platform (linux g++ 13.3, windows MSVC 19.51, macos AppleClang 21 on arm64, the four QEMU legs on arm-none-eabi-gcc 13.2.1 at N <= 4096): 0 ulp everywhere, with the reason and the one case not measured (x86 -march). - transliteration rules: what the port did with them (int indices kept, the mechanical fidelity check over 26 kernels, the cosf mutation that turns the float gates red, the objdump FMA counts 372/588 gcc and 333/221 clang at default and 0/0 with the flag, the gate's coverage). - .text per key, C vs port (+2.0 % to +3.4 % on the Ooura keys; the m55 row is CMSIS) and the instruction counts per scenario with the port rows, ratios (0.94 to 0.999 vs the C; 1.7 to 1.9 vs CMSIS on m55) and the checksum agreement, read against the 2b ratchet. - shareability row and provenance section: the port's const transforms and where it landed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
Spot-checked every number in the Stage 2a tables against the CI logs of run 35295206578 (max-ulp per platform) and bench run 35295262684 (instruction counts, .text); all match. Two labels did not: - the sentence under the max-ulp table counted "three" FMA-capable legs while listing four (macOS arm64, M4F, M33, M55); now "four". - the instruction-count preamble called the bench's Release build "-O2". No toolchain file sets an optimisation level (they set only the _INIT target flags), so Release is CMake's GNU default, -O3 -DNDEBUG; the text now says so. (bench.yml line 140 carries the same "-O2" in a comment; a workflow file is out of this docs commit's scope.) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
tap
left a comment
There was a problem hiding this comment.
Hostile review A — numerics and transliteration fidelity (head 9df52d2)
Reviewed in a detached worktree of the branch; every claim below was checked against source, a local build, a local run, or the cited CI job log. Toolchains: g++ 13.3.0, clang++ 18.1.3, clang-tidy-18, clang-format 18.1.3 (the pinned pre-commit rev), python3.
Findings
Blockers: none. Should-fix: none. Three nits, all documentation/consistency; none touches the transform, the tables, the gate or the ratchet.
-
nit —
docs/fft-design.md:172. "the three FMA-capable legs (macOS arm64, M4F, M33, M55)" names four legs; the table above it has four FMA rows. Fix: "four". (Line comment.) -
nit —
bench/README.md:114vsbench/icount/CMakeLists.txt:61vsbench/CMakeLists.txt:49. The README says the_portbinaries are always built "whatever the variable says"; the icount block skips them whenTAP_DSP_BENCH_ENGINE=split_radix, while the size-probe list addsrfft_f32_512_portunconditionally (two identical port probes in that configuration, and no port icount pair). Verified by configuring the host bench with-DTAP_DSP_BENCH_ENGINE=split_radixand listing the built targets. Fix: make the two CMake lists agree (the icount guard is the sensible one; apply it to the probe) and qualify the README sentence. CI never sets the variable, so no measured number is affected. (Line comment.) -
nit —
scripts/icount.py:31(and the same phrase inbench/README.mdand thebench.ymlheader). "it cannot fail the run" holds for the verdict but not for the process: a_portbinary that hangs, faults or printsok=0hits theraise SystemExitinmeasure()(lines 86/90) before the gated scenarios are judged, so the informational binary can still turn the job red. Probably the desired behaviour; the wording should say so, ormeasure()should catch it for informational binaries and printdid not complete. (Line comment.)
Checked and found correct (the fix pass need not re-verify)
Transliteration fidelity (attack 1). Wrote an independent normalizer (strip comments/whitespace, drop the C's local prototypes and USE_CDFT_THREADS blocks, map Sample→double, const int*/const Sample*→bare pointer, drop static/noexcept, then tokenize) and compared all 28 routines against fftsg.c. Result: 24 routines token-identical (makeipt, bitrv2, bitrv2conj, bitrv216, bitrv216neg, bitrv208, bitrv208neg, cftfsub, cftbsub, cftf1st, cftb1st, cftrec4, cfttree, cftleaf, cftmdl1, cftmdl2, cftfx41, cftf161, cftf162, cftf081, cftf082, cftf040, cftb040, cftx020). rftfsub/rftbsub differ by exactly the one documented cast (static_cast<Sample>(0.5 - c[nc - kk]), the C's assignment narrowing); makewt/makect differ only by inserted static_cast<double>(/std::/) tokens — no operand reordered, no regrouping, no type of a local changed. The rdft glue (xi = a[0] - a[1]; a[0] += a[1]; a[1] = xi;, a[1] = 0.5 * (a[0] - a[1]); a[0] -= a[1];, the n > 4 / n == 4 conditions, the nw/nc first-call protocol with ip[0] = ip[1] = 0 on entry) matches rdft statement for statement; m_nw/m_nc equal what the C re-reads from ip[0]/ip[1] on every later call, including the N = 4 case where makect is never called and nc stays 1, and N = 8 where makewt(2) writes nothing to w and makect(2) writes c[0], c[1]. Branch coverage of the sweep: cftfsub's five size branches (n > 512, > 128, ≤ 128, == 32, < 32, == 8, == 4), bitrv2's l == 8 / else (alternating powers of two), makewt's nwh == 4 / nwh > 4, makeipt's l > 32 loop (from N = 256 up) and the deep cftrec4/cfttree path (2^20) are all reached by 4 … 65536 + 2^20.
Table semantics per precision (attack 2). Every libm call in makewt/makect/the constructor takes a double argument via explicit cast (std::atan(1.0), std::cos(static_cast<double>(...)), std::sin(...), std::sqrt(static_cast<double>(n) / 2.0)); the float products delta * static_cast<Sample>(nwh), delta * 2, delta * 6, 3 * delta * static_cast<Sample>(j) are Sample arithmetic with the C's left-to-right grouping; 0.5 / cos(...), 0.5 / wk1r, 0.5 / wk3r, 0.5 * c[0], 0.5 * cos(...), 0.5 * sin(...), 0.5 * (a[0] - a[1]), 0.5 - c[nc - kk] are double arithmetic converted on assignment, exactly where the C converts. No std:: overload resolves to a float function. Mutation reproduced: in a scratch copy, w[j] = static_cast<Sample>(std::cos(delta * static_cast<Sample>(j))) (i.e. cosf) turns exactly the three float gates red (first difference at N = 2048, 128 ulp fwd / 96 ulp inv; 2^20: 131072 / 262144 ulp) and leaves the three double gates green.
Constness, aliasing, UB (attack 3). Every transform helper takes const int* ip / const Sample* w, so nothing can write a table after construction (compiler-enforced); makewt/makect are reached only from the constructor. Built the gate under ASan + UBSan (-fno-sanitize-recover=all, Debug so the constructor assert is live): 6/6 pass over 4 … 65536 + 2^20, both precisions, both directions — no out-of-bounds read at N = 4 / 8 (ip = 4 ints, w = 2 / 4 samples), no signed overflow or shift UB in bitrv2/makeipt at 2^20 (largest ip write is index 511 of 727). -Wall -Wextra -Wpedantic -Wconversion -Wshadow -Werror full builds (capi + bench on) warning-free on both g++ 13.3 and clang++ 18.1; ctest 283/283 on each.
Parity gate integrity (attack 4). expect_identical_all_sizes runs every size × every material for the given direction; the four gates cover {double, float} × {forward, inverse}; the 2^20 test covers both directions per material; the inverse is fed the reference forward's output on both sides. sweep_sizes() clamps at TAP_DSP_PARITY_MAX_N with a static_assert that it is a power of two ≥ 4 (no vacuous pass); the 2^20 test is compiled out, not skipped, below the cap (bare-metal main counts SKIP as a failure). tap_dsp_fft_parity gets -ffp-contract=off on its own TU (which is where the header-only port is compiled) and links tap_dsp_fft_reference_nocontract; tap_dsp_fft_parity_default_flags links tap_dsp_fft_reference_default with no flag on either side. Neither parity target links tap::dsp, so no backend define reaches them.
Bench and ratchet (attack 5). split_radix_bench_adapter::forward/inverse/copy are line-for-line basic_real_fft::forward/inverse/copy (if (input != output) copy loop; const Sample scale = Sample(2) / static_cast<Sample>(m_size); output[i] *= scale;); member init order matches declaration order. Host checksums C vs port identical on both compilers: 0x662dd085b5b88325 (512), 0x806d1c5dbb65c525 (2048). icount.py driven with a stubbed measure through compare / port-key-in-baseline / no-baseline / --update --record / --merge: _port never enters failures, never reaches baselines[target], is filed under top-level informational in the record file, and --merge skips that key — so the seeding path on a push to main cannot pick it up. The m55 sibling is CMSIS (documented three times: bench.yml, README, design note), and the port-vs-Ooura M55 figure is taken from m55-ooura.
Docs and numbers (attack 6). Pulled all twelve job logs. Every number in the PR body and docs/fft-design.md matches: the ten _port counts, the ten C counts (+0.00 % vs baselines.json), the ten port/C ratios, the five checksum verdicts (identical × 4 keys, DIFFER on m55), the ten .text figures and five size ratios, and the max over all N and materials = 0 ulp lines for double and float on all seven CI legs with the stated compiler ids (GNU 13.3.0, MSVC 19.51.36256.0, AppleClang 21.0.0.21000101, GNU 13.2.1 × 4). The derived figures (+2.0 … +3.4 % .text, 0.1 … 5.8 % fewer instructions, 0.4 % m55-vs-m55-ooura port delta, 1.7 – 1.9× vs CMSIS) recompute correctly. objdump FMA probe reproduced exactly (-O3 -march=haswell, vfn?m(add|sub)): gcc 372 / 588, clang 333 / 221, both 0 / 0 at -ffp-contract=off. The header banner is the design note's block verbatim plus the one modifications line, SPDX LicenseRef-Ooura AND MIT, and is consistent with NOTICE.md, LICENSES/LicenseRef-Ooura.txt and the Part 5 / Part 13 corrections (derivative-work statement, modification grant, readme path fixed by D6). The final commit 9df52d2 touches only docs/fft-design.md, so the 8afe6cf attribution of the measurements is right.
Style gates (attack 7). clang-format-18 --dry-run --Werror and pre-commit run --files clean on split_radix.h, test_fft_parity_ooura.cpp, bench_common.h; scripts/tidy.sh tests/test_fft_parity_ooura.cpp (which pulls the header through HeaderFilterRegex) clean, also with --warnings-as-errors=readability-*. (Note, pre-existing and not this PR's: the tidy compile database is configured without TAP_DSP_BUILD_BENCH, so bench/** is never tidied in CI or by tidy.sh.)
Generated by Claude Code
tap
left a comment
There was a problem hiding this comment.
Hostile review B (process, build, CI, downstream) of head a9b53b4. Numerics and transliteration fidelity are the other reviewer's. Every finding below was reproduced on a detached worktree of the branch or read from the named CI log; nothing is from memory. No blockers.
Findings
-
should-fix —
NOTICE.md(canonical provenance statement) still calls the port "planned".NOTICE.md:39"What DspTap relies on going forward: the planned C++20 port ofrdft(include/tap/dsp/fft/split_radix.h…)" and:31"What DspTap ships today: one source file of the package,fftsg.c, plus itsreadme.txt". On merge DspTap ships the derivative header, so the file thatREADME.mdanddocs/fft-design.mdboth defer to ("This file is the canonical statement") is wrong on the one fact this PR changes.docs/fft-design.md's provenance section was updated in this PR to say the port landed;NOTICE.mdwas not, and it is not a Part 12 contention file. Fix: two sentences ("landed at Stage 2a, #28, beside the C and routed nowhere until 2b; the decision on asking the author is still recorded here at 2c").README.md:436-440("planned C++20 port", "Nothing from either has shipped yet") and:449say the same thing; README is 2b's file, so that is a hand-off to the 2b README rewrite, not a change here. -
should-fix —
.github/workflows/bench.yml:168-169: the size step pipessizethroughteeunderbash -e {0}with nopipefail. Inline comment. A failingsizeon the port probe yieldsratio port/C = 0.0000and a passing step; the C line has the same pre-existing hole. TheCountstep in the same file setsset -o pipefail; Part 13 says everyteestep must. -
nit —
-Wdouble-promotion. Six lines in the float instantiation (split_radix.h:187, 245, 246, 290, 2531, 2554) fail under-Wdouble-promotion -Werroron g++ 13.3.0 and clang++ 18.1.3. Not in any Tap or MuTap flag set today; inline comment explains why it is still worth a thought for M4 soft-float consumers, and why it is the numerics reviewer's call. -
nit —
bench/CMakeLists.txt:49has noNOT TAP_DSP_BENCH_ENGINE STREQUAL "split_radix"guard on the_portsize probe whilebench/icount/CMakeLists.txt:61has one; with the non-default engine the port is compared against itself. Inline. -
nit —
bench.yml:140comment "Release (-O2)" is contradicted by this PR's own last commit (a9b53b4: Release is CMake's GNU default-O3 -DNDEBUG, no toolchain file overrides it). The commit message says a workflow file was out of that commit's scope, but the PR touchesbench.yml, so the one-word fix belongs here rather than in a later PR. -
nit —
docs/fft-design.md:48still carriesTODO(stage 2a)/ "pinned at Stage 2a" for the noise-floor row; this is the Stage 2a PR and it does not do it. Inline; re-label to the owner. -
nit — PR body says "Six commits"; there are seven (
a9b53b4, the docs label fix, was added after the body was written). The commit list in the body is otherwise accurate. -
note —
scripts/icount.py:108--mergefilters only the top-levelinformationalkey, not a_portkey nested under a real target; no pipeline path produces one. Inline, no change requested.
Verified correct
- Scope. Nine files; none of
fft.h,README.md,CLAUDE.md,ci.yml,tests/CMakeLists.txt,test_fft.cpp,test_fft_oracle.cpp,test_fft_rt.cpp,third_party/**, or the style configs. The only edit totests/test_fft_parity_ooura.cppis theengine_under_testalias plus its comment; thefft.hinclude there is still load-bearing (it supplies therdft/rdft_fprototypesooura_refcalls). Nothing routed, nothing of 2b/2c. - Header-only, standalone. A TU including only
split_radix.h, and one instantiating both precisions, compile clean under-std=c++20 -Wall -Wextra -Wconversion -Wsign-conversion -Wshadow -pedantic -Werroron g++ 13.3.0 and clang++ 18.1.3, also with-fno-exceptions -fno-rtti -DNDEBUG. Five standard includes, no macro defined, no statics or globals (every helper is a private static member),clang-tidy-18with the repo.clang-tidyclean over the header,clang-format --dry-run --Werrorclean on the three C++ files, each of the first three commits' header compiles on its own as the body claims. The constructor'sassertis the same idiom asfft.h:340; Stage 6 ownsTAP_EXPECTS. - Compile time. Both precisions at
-O2: g++ 1.72 s, clang++ 1.10 s (this host;fftsg.cas C++ 0.94 / 0.73 s, the two C files as C 1.67 s gcc). Same order as Part 4's 1.3 s; D8 stands. - Test registration.
tap_dsp_fft_paritylinkstap_dsp_fft_reference_nocontract+tap_dsp_warningsonly (tests/CMakeLists.txt:221-223), nottap::dsp; gtest names unchanged, soci.yml's-L parity -R default_flags -Vstep still matches (ran on linux, job 105446230919). QEMU legs at9df52d2(run 35295707940): all four reportTAP_DSP_TESTS_COMPLETE rc=0withselected=223/4/1 skipped=0; the parity binaries ran whole at N ≤ 4096 (the two 2^20 tests are compiled out under the cap, as documented); M55tap_dsp_tests.text1,077,672 + heap 2,087,896 fits AN547. - CMake/CI.
bench/icountsecond loop goes through the same function, so the smoke test stays host-only and the_portbinaries build on every leg (the QEMU jobs need them); the_portsize probe builds onGenericbecausebench.ymlbuilds it.TAP_DSP_BENCH_ENGINEdefault unchanged and only visible underTAP_DSP_BUILD_BENCH. icount.py. Exercised compare / no-baseline /--update/--record/--mergewith a stubbed QEMU:_portnever affects the exit code, a baseline naming a_portkey printsIGNOREDand--updatedrops it,--recordfiles it underinformational,--mergeskips that key; the seed-only-on-mainlogic is untouched andbaselines-<key>artifacts come from--update, which never writes_port. The informational lines appear in the job log (m33 105446394276, m4-softfp 105446394297, m55 105446393983) and are appended to the step summary viatee→icount.txt; the size line is echoed and appended too.- Provenance. The header's notice block is token-identical to
LICENSES/LicenseRef-Ooura.txtand tothird_party/ooura/readme.txt:141-145;NOTICE.md's quoted grant matches; the derivative-work sentence is present; no "unmodified", "public domain" or "permissive" wording was added;docs/fft-design.md's banner block matches the header except the documented extra "Modifications (…) September 2026" sentence. - Numbers traced to logs. m33 counts 100,833,265 / 115,396,527 and
.text42,601 / 44,009 (105446394276); m4-softfp 1,864,904,581 / 2,294,343,851 (105446394297); m55.text107,681 / 39,281 (105446393983); ulp 0 / 0 on linux (105446230919), macos (105446230934), cortex-m55 (105446230931); runner images ubuntu-24.04 / macos-26-arm64 / windows-2025-vs2026, compilers GNU 13.3.0 / AppleClang 21.0.0.21000101 / MSVC 19.51.36256.0 / arm-none-eabi-gcc 13.2.1 (15:13.2.rel1-2), QEMU 8.2.2 (1:8.2.2+ds-0ubuntu1.18) — all as the design note states.bench/README.md's tables agree withbench/baselines.json(unchanged) and the new_portsemantics. The.textdeltas (+776 … +1,560 B, +2.0 … +3.4 %) and the "0.1 – 5.8 % fewer instructions" arithmetic check out. - Downstream (MuTap). Scratch copies of MuTap configured with
-DMUTAP_WERROR=ONagainst DspTaporigin/mainand against this tree:CMakeCache.txt, target list and configure output identical;mutap_testsbuilds warning-free with-Werroragainst this tree (its suite was 123/197 green and still running at post time; nothing in it can see the port). Nothing in MuTap includes the new header; MuTap's CIgcc -c submodules/dsptap/third_party/ooura/fftsg*.cpath is untouched; no new cache variable, option, CMake minimum or default. The "no pin bump needed" claim holds. - Hygiene. All seven commits carry both trailers; PR footer present; no other model identifiers in the diff; PR template sections filled; each commit message matches its diff. Local: full build
-DTAP_DSP_WERROR=ON -DTAP_DSP_BUILD_BENCH=ON -DTAP_DSP_BUILD_CAPI=ON,ctest283/283. Heada9b53b4: all 24 check runs green.
Generated by Claude Code
Review B1 on #28: the canonical statement still called the C++20 port "planned" and described the vendored fftsg.c as everything DspTap ships. Post-2a truth, stated in the same bullet: the port exists at include/tap/dsp/fft/split_radix.h beside the vendored C, is bit-identical to it for both precisions under the parity gate, and is routed nowhere until Stage 2b flips basic_real_fft at it; the C leaves the shipping tree at Stage 2c. "SPDX plan" becomes "SPDX" now that the header carries it. No licensing fact moves: the grant quotation, the derivative-work and modification-grant sentences, the readme.txt-stays-permanently point and the LicenseRef-Ooura AND MIT identifier are as wave 1 settled them. README.md's matching "planned" sentences belong to the Stage 2b README rewrite (audit Part 3) and are not touched here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
…step fails loud Reviews A2/A3 and B2/B4/B5/B8 on #28. - bench.yml, size step (B2): set -eo pipefail (Part 13: every step that pipes through tee sets it; the Count step already did), and an explicit "<C|port> .text unavailable" error when size -A yields no numeric .text row for either probe. Before, a failing arm-none-eabi-size printed an empty figure, a 0.0000 ratio and a green step, and the C ceiling comparison [ "" -gt ceiling ] was silently skipped inside the if. - bench.yml (B5): the workloads comment said "Release (-O2)"; Release is CMake's GNU default, -O3 -DNDEBUG, as a9b53b4 already corrected in the design note. Header comment states precisely what "informational" holds for the icount and size paths. - bench/CMakeLists.txt (A2/B4): the _port size probe now follows the same guard as the _port icount pairs and is skipped when TAP_DSP_BENCH_ENGINE is already split_radix (the probe would be the port compared to itself). Verified by configuring both ways: default builds the two icount _port pairs and the _port probe; split_radix builds none of the three. - scripts/icount.py (A3): measure() raises MeasurementError instead of SystemExit. A gated binary that yields no count still aborts the run as before; an informational (_port) binary that times out, faults or prints ok=0 is reported as "<key>: informational binary failed: <reason>" in the informational block and the run continues to the gated verdict, so the docstring's "never a gate entry" is now true of the process as well as the verdict. (B8): --merge drops a _port key nested under a real target, not only the top-level "informational" key, so a hand-edited record file cannot seed one. Driven with a stubbed measure() through compare, no-baseline, port-key-in-baseline, --update --record, informational timeout, informational ok=0, gated failure and --merge with a smuggled key; outputs in the PR comment. - bench/README.md: the _port sentence says the actual rule (built at the default; not when the variable is already split_radix), the informational paragraph says exactly what holds, and the Sizes policy notes the pipefail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
Review B6 on #28: the contract table's noise-floor row and the split-radix rationale paragraph still said the float 1.105e-7 probe value and the double figure against the compensated-DFT oracle are "re-measured and pinned at Stage 2a". Stage 2a did not and could not: its scope is the port and its parity gate, and tests/test_fft.cpp is untouched by it. Labelled Stage 2b, not later: the audit (Part 3) does not name the stage that pins these two numbers, and 2b is the PR that re-points the existing battery at the port and rewrites the README, so a number measured then is measured on what ships; 3b's widening of test_fft.cpp is over the fixed-point profiles, which these two rows are not about. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
Review B3 on #28. In the float instantiation -Wdouble-promotion (g++ 13.3 and clang++ 18.1, not in any Tap flag set) fires on six lines: 0.5 * (a[0] - a[1]), 0.5 / wk1r, 0.5 / wk3r, 0.5 * c[0] and the two 0.5 - c[nc - kk]. Those are the intended rule-2 doubles, where the C's float build promoted to a double literal in exactly the same places, and the statements stay textually intact (rule 1; the reviewers' fidelity normalizer and the cosf mutation check are evidence attached to the current text). One sentence added to rule 2 in the banner saying so and that no cast is to be added to silence it. Comment-only: the parity label re-run 7/7 on g++ and clang++. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
Fix pass for reviews A (#pullrequestreview-5243359055) and B (#pullrequestreview-5243370615)Head
Hand-off to Stage 2b (not changed here)
Local verification of
|
What this changes
Stage 2a of
docs/audit-fft-and-code-smells.md(Part 3; spec in Part 4, provenance in Part 5). Addstap::dsp::detail::split_radix_rdft<Sample>(include/tap/dsp/fft/split_radix.h), a statement-for-statement C++20 transliteration of therdftpath of Ooura'sfftsg.c, beside the vendored C. Nothing is routed:fft.his untouched,basic_real_fftstill runs the C. The parity gate'sengine_under_testalias is re-pointed at the port (the one place its header comment names), the bench builds every float scenario and the float size probe a second time against the port under a_portsuffix and reports port/C without gating it,bench/README.mdrecords the mechanism, anddocs/fft-design.mdrecords the measured numbers below from this PR's own CI logs (run and job ids on every number).Eleven commits: the six of the original series (tables, bit reversal, complex kernel, real post-pass + transforms + gate re-point, bench, docs), a docs label correction (
a9b53b4), and four review-fix commits after the two hostile reviews (d137a2eNOTICE.md provenance post-2a;37ca717bench: an informational port binary never reds the ratchet, size steppipefailand explicit failure on a missing.text,_portsize probe under the same engine guard as icount,--mergedrops nested_portkeys;cb92d50noise-floor TODO re-labelled to Stage 2b;d8eccf6rule-2 sentence naming the intentional-Wdouble-promotionsites). Each of the first three compiles standalone; the class is complete in the fourth, which is where the parity target starts linking against it.Why
The port is the long pole of the FFT plan: Stage 2b's routing flip (one line in
fft.h), the MuTap bump with every float pin unchanged, and Stage 2c's removal of the C all rest on the port being bit-identical to the C for both precisions (Decision D10). This PR is where that identity is established and gated, with the C still in the tree to compare against.How identity was achieved and how it is kept, stated in the header so nobody undoes it:
cmulhelpers, lambdas,std::complexor reassociation). Verified mechanically as well as by the gate: a normalizer that strips whitespace, mapsSample→doubleand undoes only the documented differences finds all 26 mechanically ported kernels token-identical to the C (DIFFERENCES: 0).#define double float, somakewt/makect's locals and the products fed to the trig calls are float arithmetic whilecos/sin/atanstay the double libm functions with promoted arguments and the results converted on assignment (and0.5 / cos(...),0.5 / wk1r,0.5 * cos(...)are double arithmetic converted on assignment). The port keeps the locals typedSample, gives every libm call an explicitstatic_cast<double>argument and converts toSampleexactly where the C's assignment did. Breaking this on one line (std::cosof the float product, i.e.cosf) turns the three float gates red while the three double gates stay green.rdft's first-call protocol; the lazyip[0] = 0init is gone, the transforms areconst noexcept, andm_w.data()/m_ip.data()are loaded into locals once per transform so the helpers receivea,ip,wby parameter as the C functions do.int(Part 4 permits widening; not widening keeps the statements identical); the singlesize_t→intnarrowing is the constructor's.Not ported:
cdft, the DCT/DST family, the thread scaffolding.Verification
The gate (
-ffp-contract=offboth sides, memcmp identity)Green on the first CI run of the PR on every leg: linux (g++ 13.3.0), windows (MSVC 19.51.36256.0), macos (AppleClang 21.0.0, arm64), and at N ≤ 4096 against newlib's libm on cortex-m4-softfp, cortex-m4f, cortex-m33, cortex-m55 (arm-none-eabi-gcc 13.2.1, QEMU 8.2.2). Locally also clang++ 18.1.3. Every N from 4 to 65536 plus 2^20 (hosts), double and float, forward and inverse, five materials.
Informational max-ulp at default flags (CI run 35295206578 at
8afe6cf, 2026-09-18)doublefloat-march-marchZero everywhere because the statements are identical, so each compiler fuses both sides alike. Not measured and not claimed: x86-64 with
-march(FMA), where the objdump probe shows g++ fusing the two sides differently after inlining. The gate stays at-ffp-contract=off.objdump FMA probe (
-O3 -march=haswell; reference C =fftsg.c+fftsg_float.c; port = a TU instantiating both precisions)-ffp-contract=off: C-ffp-contract=off: portBench, informational (bench run 35295262684 at
8afe6cf, arm-none-eabi-gcc 13.2.1, QEMU 8.2.2; nothing gated at the port, no seeding needed, all fiveicountjobs green with the C at +0.00 %)m4-softfpm4fm33m55(C = CMSIS)m55-oourarfft_f32_512(C, seeded baseline)rfft_f32_512_portrfft_f32_2048(C, seeded baseline)rfft_f32_2048_port.textof the MinSizeRel float size probe (size -A), bytes:Read against the ±3 % ratchet 2b applies: the port executes 0.1 – 5.8 % fewer instructions than the C on every Ooura key and is bit-identical to it at the bench's default flags (identical checksums, including on the VFMA cores);
m55-oouraimproves beyond 3 %, so 2b re-records that key rather than absorbing it. Against CMSIS-DSP Helium on the deployedm55profile the port is 1.7 – 1.9× the count;m55stays on CMSIS.Local
g++ 13.3.0 and clang++ 18.1.3, Release,
-DTAP_DSP_WERROR=ON -DTAP_DSP_BUILD_CAPI=ON -DTAP_DSP_BUILD_BENCH=ON: full builds warning-free,ctest283/283 each (incl. the new_portsmoke tests; host checksums C vs port identical:0x662dd085b5b88325at N = 512,0x806d1c5dbb65c525at N = 2048). Mutation check as described above (3 float gates red / 3 double green, restored 7/7).scripts/icount.py's informational path dry-run with a stubbed QEMU measure through compare, no-baseline,--update,--mergeand ignored-baseline cases.scripts/tidy.shfull sweep (clang-tidy-18) clean;pre-commit run --all-filesclean for this PR's files (it reformats the pre-existingtests/reference/frontend_vectors.h, not touched here).Notes for the reviewer
//prose block carrying Ooura's notice verbatim and the derivative statement, exactly the textdocs/fft-design.mdandNOTICE.mdsettled in wave 1; SPDXLicenseRef-Ooura AND MIT.basic_real_fftruns today and nothing is routed at it. No consumer pin moves.fft.h(2b flips routing),README.md,tests/CMakeLists.txt(the parity targets already existed),ci.yml,third_party/**, the taphouse style files.scripts/icount.pyis touched, per the bench brief's "documented informational prefix in icount.py" option: the mechanism lives where the QEMU invocation already is, so the ratio prints next to the counts.m55key's C sibling is CMSIS-DSP, so its printed port/C ratio is port-vs-CMSIS; the port-vs-Ooura number on the M55 is them55-oourakey's.basic_real_fftcan hold asplit_radix_rdft<Sample>by value; its transforms areconst, soforward_inplace/inverse_inplaceon the wrapper can becomeconsttoo. The engine's constructor allocates (two vectors) and asserts the geometry the same wayfft.hdoes. Expecticount.pyto flagm55-oouraas IMPROVED beyond tolerance at the flip (re-record, as the policy says).🤖 Generated with Claude Code
https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy