diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e9b2c5..63cdd56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,17 +147,16 @@ jobs: UBSAN_OPTIONS: print_stacktrace=1 run: ctest --test-dir build ${{ env.CTEST_COMMON }} ${{ matrix.labels }} - # The family's error model, A3: every engine's headers and the three C - # ABIs build with exceptions off (clang, -fno-exceptions, warnings fatal). - # Tests and examples are off: gtest's EXPECT_THROW and the examples' catch - # blocks need exceptions, and the headers' own exceptions-off compile is - # the hosted legs' .Family.HeadersCompileWithoutExceptions. What - # this job adds is the C boundary: the create paths validate first and - # carry no try/catch in such a build. + # The family's error model, A3, as a consumer that forbids both exceptions + # and RTTI builds it (the roadmap's Q5 and A12): every engine's battery, + # examples and C ABI under -fno-exceptions -fno-rtti (TAP_SR_NO_EXCEPTIONS_RTTI), + # warnings fatal. Rejections are death tests here (tests/support/tap_sr_test/ + # expect_rejected.h) and the C ABIs' create paths validate first and carry + # no try/catch. The job keeps its A3 name so its check stays the same. no-exceptions: name: Linux Clang, -fno-exceptions (engines + C ABIs) runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 30 steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: @@ -170,13 +169,16 @@ jobs: run: > cmake -B build -DCMAKE_BUILD_TYPE=Release - -DCMAKE_CXX_FLAGS=-fno-exceptions - -DTAP_SR_BUILD_TESTS=OFF -DTAP_SR_BUILD_EXAMPLES=OFF -DTAP_SR_BUILD_CAPI=ON + -DTAP_SR_NO_EXCEPTIONS_RTTI=ON + -DTAP_SR_BUILD_TESTS=ON -DTAP_SR_BUILD_EXAMPLES=ON -DTAP_SR_BUILD_CAPI=ON -DTAP_SR_ASYNC_WERROR=ON -DTAP_SR_BRIDGE_WERROR=ON -DTAP_SR_RATIONAL_WERROR=ON - name: Build run: cmake --build build -j 4 + - name: Test + run: ctest --test-dir build ${{ env.CTEST_COMMON }} -j 4 + # Cross-compiled correctness under emulation, one job per (target, engine): # every engine is built in one tree and each job runs one engine's label # with that engine's own exclusions and parallelism, as each repository ran @@ -292,6 +294,120 @@ jobs: build_type: MinSizeRel toolchain: cmake/arm-cortex-m33-mps2.cmake exclude: "" + # The same nine legs as a consumer that forbids exceptions and RTTI + # builds them (the roadmap's Q5 and A12: a current one ships every + # engine this way on these three targets), with each leg's own + # exclusions and budget. + - target: hexagon + title: Hexagon cross, no exceptions/RTTI + engine: async + label: async + timeout: 45 + jobs: 1 + build_type: Release + toolchain: cmake/hexagon-linux-musl.cmake + exclude: AsrcQuality|AsrcLock|TwoThreadStress|Threads\.|TransparentPrototypeMeetsSpec|MultiChannel\.|Feasibility|Reset\. + cmake_extra: -DTAP_SR_NO_EXCEPTIONS_RTTI=ON + suffix: -no-exceptions-rtti + - target: hexagon + title: Hexagon cross, no exceptions/RTTI + engine: bridge + label: bridge + timeout: 30 + jobs: 4 + build_type: Release + toolchain: cmake/hexagon-linux-musl.cmake + exclude: "" + # rational: the host measurements are excluded on Hexagon (the two + # unmeetable-spec tests, which search every length on the design + # grid in soft double and outlast the per-test timeout; the coverage matrix's tone batteries, 182 chains in double per + # profile, and the relaxation tables' searches, which run every + # candidate length through the design grid: the 70 dB tables alone + # exceed the job's budget under qemu-hexagon, and the quantized and + # relaxed Q15 tables' stopbands through a 16384-point DFT); on the Arm legs + # bare_metal_main.cpp bakes in its own + # filter (the fine-grid sweeps of the long designs and the + # matrix suite). + cmake_extra: -DTAP_SR_NO_EXCEPTIONS_RTTI=ON + suffix: -no-exceptions-rtti + - target: hexagon + title: Hexagon cross, no exceptions/RTTI + engine: rational + label: rational + timeout: 20 + jobs: 4 + build_type: Release + toolchain: cmake/hexagon-linux-musl.cmake + exclude: UnmeetableSpec|Matrix\.EveryRowMeets|RelaxationTablesAreTheSearch|QuantizedTablesAttainTheirStatedStopbands|RelaxedQ15TablesStopbandsAreStated + cmake_extra: -DTAP_SR_NO_EXCEPTIONS_RTTI=ON + suffix: -no-exceptions-rtti + - target: m55 + title: Cortex-M55 cross, no exceptions/RTTI + engine: rational + label: rational + timeout: 20 + jobs: 1 + build_type: MinSizeRel + toolchain: cmake/arm-cortex-m55-mps3.cmake + exclude: "" + cmake_extra: -DTAP_SR_NO_EXCEPTIONS_RTTI=ON + suffix: -no-exceptions-rtti + - target: m33 + title: Cortex-M33 cross, no exceptions/RTTI + engine: rational + label: rational + timeout: 20 + jobs: 1 + build_type: MinSizeRel + toolchain: cmake/arm-cortex-m33-mps2.cmake + exclude: "" + cmake_extra: -DTAP_SR_NO_EXCEPTIONS_RTTI=ON + suffix: -no-exceptions-rtti + - target: m55 + title: Cortex-M55 cross, no exceptions/RTTI + engine: async + label: async + timeout: 30 + jobs: 1 + build_type: MinSizeRel + toolchain: cmake/arm-cortex-m55-mps3.cmake + exclude: "" + cmake_extra: -DTAP_SR_NO_EXCEPTIONS_RTTI=ON + suffix: -no-exceptions-rtti + - target: m55 + title: Cortex-M55 cross, no exceptions/RTTI + engine: bridge + label: bridge + timeout: 20 + jobs: 1 + build_type: MinSizeRel + toolchain: cmake/arm-cortex-m55-mps3.cmake + exclude: "" + # ~23 min of emulated suite against a 40-minute budget. + cmake_extra: -DTAP_SR_NO_EXCEPTIONS_RTTI=ON + suffix: -no-exceptions-rtti + - target: m33 + title: Cortex-M33 cross, no exceptions/RTTI + engine: async + label: async + timeout: 40 + jobs: 1 + build_type: MinSizeRel + toolchain: cmake/arm-cortex-m33-mps2.cmake + exclude: "" + cmake_extra: -DTAP_SR_NO_EXCEPTIONS_RTTI=ON + suffix: -no-exceptions-rtti + - target: m33 + title: Cortex-M33 cross, no exceptions/RTTI + engine: bridge + label: bridge + timeout: 20 + jobs: 1 + build_type: MinSizeRel + toolchain: cmake/arm-cortex-m33-mps2.cmake + exclude: "" + cmake_extra: -DTAP_SR_NO_EXCEPTIONS_RTTI=ON + suffix: -no-exceptions-rtti env: # Prebuilt open-source toolchain (BSD-3) published by Qualcomm/Quicinc; # binary artifacts are hosted on CodeLinaro and linked from the @@ -372,6 +488,7 @@ jobs: -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} -DCMAKE_TOOLCHAIN_FILE=${{ matrix.toolchain }} -DTAP_SR_BUILD_EXAMPLES=OFF + ${{ matrix.cmake_extra }} - name: Build run: cmake --build build -j 4 @@ -380,7 +497,7 @@ jobs: shell: bash run: | args=(--test-dir build ${{ env.CTEST_COMMON }} -L '^${{ matrix.label }}$' - -j ${{ matrix.jobs }} -V --output-log ctest-${{ matrix.target }}-${{ matrix.engine }}.log) + -j ${{ matrix.jobs }} -V --output-log ctest-${{ matrix.target }}-${{ matrix.engine }}${{ matrix.suffix }}.log) if [ -n '${{ matrix.exclude }}' ]; then args+=(-E '${{ matrix.exclude }}'); fi ctest "${args[@]}" @@ -388,8 +505,8 @@ jobs: if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: ctest-${{ matrix.target }}-${{ matrix.engine }} - path: ctest-${{ matrix.target }}-${{ matrix.engine }}.log + name: ctest-${{ matrix.target }}-${{ matrix.engine }}${{ matrix.suffix }} + path: ctest-${{ matrix.target }}-${{ matrix.engine }}${{ matrix.suffix }}.log # ------------------------------------------------------------------------ # Template: genuine Tensilica HiFi4/HiFi5 coverage. The HiFi audio ISA, @@ -608,6 +725,43 @@ jobs: if: ${{ !cancelled() }} run: python3 scripts/ceilings.py measure --engine rational --target ${{ matrix.target }} --build-dir build + # The same workloads as the family's exceptions-off consumer builds + # them (TAP_SR_NO_EXCEPTIONS_RTTI, the roadmap's A12), counted against + # the SAME baselines: the engines' hot paths are their own (out-of-line + # entries, rational's cursor), so the two configurations are identical + # to the instruction, and this is what pins that. The ceilings are + # recorded for the exceptions build only. + - name: Build workloads without exceptions or RTTI + if: ${{ !cancelled() }} + run: | + case "${{ matrix.target }}" in + m55) tc=cmake/arm-cortex-m55-mps3.cmake ;; + m33) tc=cmake/arm-cortex-m33-mps2.cmake ;; + hexagon) tc=cmake/hexagon-linux-musl.cmake ;; + esac + cmake -B build-noexc -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE=$tc \ + -DTAP_SR_BUILD_TESTS=OFF -DTAP_SR_BUILD_EXAMPLES=OFF \ + -DTAP_SR_BUILD_ICOUNT_BENCH=ON -DTAP_SR_NO_EXCEPTIONS_RTTI=ON + cmake --build build-noexc -j 4 + + - name: Ratchet async (no exceptions, no RTTI) + if: ${{ !cancelled() }} + run: > + python3 scripts/icount.py --engine async --target ${{ matrix.target }} + --build-dir build-noexc --plugin /tmp/libinsncount.so + + - name: Ratchet bridge (no exceptions, no RTTI) + if: ${{ !cancelled() }} + run: > + python3 scripts/icount.py --engine bridge --target ${{ matrix.target }} + --build-dir build-noexc --plugin /tmp/libinsncount.so + + - name: Ratchet rational (no exceptions, no RTTI) + if: ${{ !cancelled() }} + run: > + python3 scripts/icount.py --engine rational --target ${{ matrix.target }} + --build-dir build-noexc --plugin /tmp/libinsncount.so + # The host ratchet (the roadmap's A8): each engine's float converter in # 128-frame blocks at 2 and 12 channels, built natively and counted under # callgrind (/bench/icount/host_main.cpp, scripts/icount.py @@ -660,6 +814,29 @@ jobs: if: ${{ !cancelled() }} run: python3 scripts/icount.py --engine rational --target host --build-dir build + # The exceptions-off configuration (A12), against the same baselines. + - name: Build the host workloads without exceptions or RTTI + if: ${{ !cancelled() }} + run: | + cmake -B build-noexc -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=g++-13 \ + -DTAP_SR_BUILD_TESTS=OFF -DTAP_SR_BUILD_EXAMPLES=OFF -DTAP_SR_BUILD_ICOUNT_BENCH=ON \ + -DTAP_SR_NO_EXCEPTIONS_RTTI=ON + targets=$(cmake --build build-noexc --target help | sed -n 's/^\(tap_sr_[a-z]*_hostcount_[a-z0-9_]*\):.*/\1/p') + test -n "$targets" + cmake --build build-noexc -j 4 --target $targets + + - name: Ratchet async (no exceptions, no RTTI) + if: ${{ !cancelled() }} + run: python3 scripts/icount.py --engine async --target host --build-dir build-noexc + + - name: Ratchet bridge (no exceptions, no RTTI) + if: ${{ !cancelled() }} + run: python3 scripts/icount.py --engine bridge --target host --build-dir build-noexc + + - name: Ratchet rational (no exceptions, no RTTI) + if: ${{ !cancelled() }} + run: python3 scripts/icount.py --engine rational --target host --build-dir build-noexc + # Each engine README's instruction-count table derives 1:1 from its # committed baselines, and its ceilings tables from bench/ceilings.json; # regenerating them must produce no diff. diff --git a/CLAUDE.md b/CLAUDE.md index 2ce26ee..eef2a4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,14 @@ made this tree (sections 5–6). Do not re-derive what it settles. - **Clean renames, no aliases (D7).** Retired options fail the configure (`cmake/retired_options.cmake`); retired override macros hit an `#error`; retired words fail the style job (`scripts/retired_words.txt`, `scripts/check_retired_words.py`). Do not add aliases. +- **Exceptions and RTTI stay optional (A12, Q5).** A current consumer builds all three engines + with `-fno-exceptions -fno-rtti` on the M33, M55 and Hexagon, and the 1.0 freeze covers that + configuration (P14). No header may use RTTI or rely on exceptions beyond `tap::dsp::raise`. A + test that rejects or accepts a configuration goes through `TAP_SR_EXPECT_REJECTED`, + `TAP_SR_EXPECT_REJECTED_WITH` or `TAP_SR_EXPECT_ACCEPTED` + (`tests/support/tap_sr_test/expect_rejected.h`), never `EXPECT_THROW` or a `try` of its own; + `-DTAP_SR_NO_EXCEPTIONS_RTTI=ON` reproduces the gated build, and CI runs it hosted and on the + nine QEMU legs. - **One meaning per profile name (A4, the 2026-10 audit response).** Each engine's quality tiers are its own ladder, stated with its numbers in the root README's profile table, and a profile name means one thing across the family: `bridge` and `rational` share four names at the same @@ -84,7 +92,9 @@ over statistical sampling, and measured numbers stated with their provenance. (`cmake/arm-cortex-*.cmake`, one-shot `bare_metal_main.cpp` per engine) and on Hexagon under `qemu-hexagon`, and the instruction-count ratchet gates every workload of every engine two-sided at ±3 % (`scripts/icount.py --engine async|bridge|rational --target m33|m55|hexagon`, baselines in -`/bench/baselines.json`). A change that moves a count beyond tolerance re-records the +`/bench/baselines.json`), built with exceptions and again without them or RTTI +(`TAP_SR_NO_EXCEPTIONS_RTTI`) against the same baselines: the engines' hot paths are their own +(A12), so the two builds count the same. A change that moves a count beyond tolerance re-records the baseline in the same PR; an improvement beyond tolerance fails too, by design. Guest markers (`SRT_ICOUNT_DONE`, `RATIO_ICOUNT_DONE`, `RATIONAL_ICOUNT_DONE`) are part of the counted binaries and never change. A `callmax_*` scenario is the worst single call instead of a total diff --git a/CMakeLists.txt b/CMakeLists.txt index bdef9d2..d29e8e9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,6 +28,23 @@ option(TAP_SR_BUILD_COMPARE_SHIM "Build the comparison notebooks' competitor shi option(TAP_SR_ASYNC_WERROR "Treat warnings as errors in the async engine's own targets" OFF) option(TAP_SR_BRIDGE_WERROR "Treat warnings as errors in the bridge engine's own targets" OFF) option(TAP_SR_RATIONAL_WERROR "Treat warnings as errors in the rational engine's own targets" OFF) +# The configuration a consumer that forbids both builds every engine in (the +# roadmap's Q5 and A12: a current one does, on the M33, M55 and Hexagon): +# every target this tree adds from here on, GoogleTest and every engine's +# battery, examples, C ABI and ratchet workloads, compiles C++ with +# -fno-exceptions -fno-rtti. The engines need nothing for it (A3: validate() +# before constructing, rejections terminate through tap::dsp::raise; no +# header uses RTTI); the option builds the evidence. An option rather than +# CMAKE_CXX_FLAGS, which would replace the cross toolchains' _INIT flags. +# Directory scope; GCC and Clang only. +option(TAP_SR_NO_EXCEPTIONS_RTTI "Build every target with -fno-exceptions -fno-rtti" OFF) +if(TAP_SR_NO_EXCEPTIONS_RTTI) + if(NOT CMAKE_CXX_COMPILER_ID MATCHES "^(GNU|Clang|AppleClang)$" + OR CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + message(FATAL_ERROR "TAP_SR_NO_EXCEPTIONS_RTTI needs GCC or Clang (got ${CMAKE_CXX_COMPILER_ID})") + endif() + add_compile_options(-fno-exceptions -fno-rtti) +endif() # GoogleTest is fetched once, by whichever engine's tests/ is configured # first; the other engines' FetchContent_MakeAvailable is then a no-op. Its diff --git a/PLAN.md b/PLAN.md index 59a830b..3667b3e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -48,7 +48,7 @@ How to read it: | D13 | **One family version, 0.4.0** at the migration (0.5.0 at `rational`'s M6, 0.6.0 at the 2026-10 audit response's async vocabulary pass), with tags `vX.Y.Z` and bit-packed encoding `(M<<16)\|(m<<8)\|p`. **Mechanics (step 3):** root `project(SampleRateTap VERSION 0.4.0)`; engine subprojects renamed `tap_sr_async` / `tap_sr_bridge` with no VERSION; macros `TAP_SR_VERSION_{MAJOR,MINOR,PATCH}` defined **token-identically** in each engine's umbrella header, checked by a static_assert test (no shared header, so 4.2 check 1 holds); each C ABI library exports its own `tap_sr__version()`; a new C ABI test `CApi.VersionIsBitPacked` pins the encoding, **which nothing pins today** (`test_skeleton.cpp` checks only MAJOR = 0) | User decision. 0.4.0 is above both current versions. The encoding is RatioTap's (`ratio_capi.cpp:97`) | | D14 | **One copyright line family-wide:** `Copyright (c) 2026 Timothy Place and the SampleRateTap contributors` in the root `LICENSE`, and the same holder in a **banner on every C/C++/Python source file** | User decision, **reconfirmed**: the user holds SampleRateTap's copyright. SampleRateTap's notice today names only "SampleRateTap contributors"; RatioTap's names the user. So this **adds the author's name** to SampleRateTap's notice, which is accurate on the user's word, and restates RatioTap's. There are 35 banner lines today; about 25 of SampleRateTap's C/C++ files have none. Banners are added everywhere in step 3.8. `STYLE.md`'s banner template ("Copyright 2025-2026 Timothy Place.") is reconciled through taphouse (step 5) | | D15 | **`async` renames its converter family to match `bridge`'s `basic_converter` family:** `basic_async_sample_rate_converter` → `basic_converter`, `async_sample_rate_converter` → `converter`, `…_q15`/`…_q31` → `converter_q15`/`converter_q31`, exception prefixes `"async_sample_rate_converter: "` → `"tap::sr::async::converter: "`, and `asrc.h` → `converter.h` | User decision. v2.1's rationale cited a `tap::sr::ratio::converter` that does not exist. The real parallel is RatioTap's (now `bridge`'s) `basic_converter` with its `converter_to_48k` family. Scale: about 80 hits in 22 files, plus the book's naming-decision prose (R2-COH-19) | -| D17 | **One error model (A3, the roadmap's Block A):** construction is where an engine rejects, processing is `noexcept`; each engine states the rejection twice in the same words — `validate(...)`, a `noexcept` precheck returning the reason as a `const char*` (nullptr when the converter constructs), and the constructor, which raises that reason through `tap::dsp::raise` (DspTap's `detail/expects.h`: a throw where the build has exceptions, `std::terminate()` where it does not). Every header compiles under `-fno-exceptions`; the C ABIs validate before constructing and return NULL. Gated by `.Family.HeadersCompileWithoutExceptions` and the `no-exceptions` CI job. | A3 (2026-10-10). The shared piece landed in DspTap first (tap/DspTap#59), per the substrate discipline. | +| D17 | **One error model (A3, the roadmap's Block A):** construction is where an engine rejects, processing is `noexcept`; each engine states the rejection twice in the same words — `validate(...)`, a `noexcept` precheck returning the reason as a `const char*` (nullptr when the converter constructs), and the constructor, which raises that reason through `tap::dsp::raise` (DspTap's `detail/expects.h`: a throw where the build has exceptions, `std::terminate()` where it does not). Every header compiles under `-fno-exceptions`; the C ABIs validate before constructing and return NULL. Gated by `.Family.HeadersCompileWithoutExceptions` and the `no-exceptions` CI job, which since A12 runs every battery under `-fno-exceptions -fno-rtti` (`TAP_SR_NO_EXCEPTIONS_RTTI`), as nine QEMU legs do on the M33, M55 and Hexagon. | A3 (2026-10-10). The shared piece landed in DspTap first (tap/DspTap#59), per the substrate discipline. A12 (2026-11-04, Q5's answer: a current project forbids both) made it every battery's configuration, the substrate first again (tap/DspTap#63). | | D16 | **Tests carry an engine prefix:** `gtest_discover_tests(… TEST_PREFIX "async." / "bridge.")`, plus a `LABELS` value of `async` / `bridge` on every test, including the bare-metal `*_tests_emulated` entries. Lands in step P.2, so the snapshot already has it, **with RatioTap's pre-rename spelling `ratio.` / `ratio`**; step 3.4 renames it to `bridge.` / `bridge`, and G1/G2 compare through `rename.py`'s name map | CTest applies a duplicate name's properties to both tests. `FixedPoint.FullScaleSineDoesNotWrapQ15` exists in both engines, so labels alias and `ctest -L ratio` selects async's copy (reproduced, R2-GATE-4). Unique names fix G1 and engine selection | --- diff --git a/README.md b/README.md index 39e8ea1..ea36f1b 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,17 @@ under which every header here compiles) the constructor's rejection nullptr. A refused allocation stays `std::bad_alloc` where exceptions exist and a termination where they do not, as the standard library's is. The C ABIs validate before constructing and return NULL, so they never unwind and never -terminate on a bad argument. CI builds every engine and the three C ABIs with -exceptions off (`no-exceptions` in `ci.yml`) and compiles every header that -way on the hosted legs (`.Family.HeadersCompileWithoutExceptions`). +terminate on a bad argument. No header uses RTTI. Every battery runs as a +consumer that forbids both builds it (`-DTAP_SR_NO_EXCEPTIONS_RTTI=ON`: +`-fno-exceptions -fno-rtti` on every target, GoogleTest included): on the +hosted `no-exceptions` job in `ci.yml` (every battery, the examples and the +C ABIs) and on the M33, M55 and Hexagon, every engine, in nine QEMU legs +beside the nine that keep exceptions. Every header also compiles with +exceptions off on the hosted legs +(`.Family.HeadersCompileWithoutExceptions`). Built that way, a test +that rejects a configuration is a death test on `std::terminate()` where +GoogleTest has death tests, and `validate()`'s text is pinned in both builds +(`tests/support/tap_sr_test/expect_rejected.h`). Every configure builds every engine. CI selects an engine only when it runs tests, by ctest label. The family options are `TAP_SR_*`: @@ -100,6 +108,7 @@ tests, by ctest label. The family options are `TAP_SR_*`: | `TAP_SR_BUILD_ICOUNT_BENCH` | OFF | every engine's instruction-count ratchet workloads | | `TAP_SR_BUILD_BENCHMARKS`, `TAP_SR_BUILD_COMPARE_BENCH`, `TAP_SR_BUILD_COMPARE_SHIM` | OFF | the async engine's host-only benchmarks; the engines' resampler comparison benchmarks; the comparison notebooks' competitor shims (`tools/compare/`) | | `TAP_SR_ASYNC_WERROR`, `TAP_SR_BRIDGE_WERROR`, `TAP_SR_RATIONAL_WERROR` | OFF | warnings as errors, per engine | +| `TAP_SR_NO_EXCEPTIONS_RTTI` | OFF | every target with `-fno-exceptions -fno-rtti` (GCC and Clang), as a consumer that forbids both builds the engines | A retired pre-family option (`SRT_*`, `TAP_RATIO_*`) fails the configure loudly (`cmake/retired_options.cmake`) rather than dropping a gate silently. diff --git a/async/tests/CMakeLists.txt b/async/tests/CMakeLists.txt index 0bc9c3c..5e1bfb5 100644 --- a/async/tests/CMakeLists.txt +++ b/async/tests/CMakeLists.txt @@ -47,7 +47,10 @@ add_executable(tap_sr_async_tests target_link_libraries(tap_sr_async_tests PRIVATE tap::sr::async tap_sr_async_warnings) -target_include_directories(tap_sr_async_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +# The family's test support (tests/support/tap_sr_test/: the rejection +# assertions, with and without exceptions; the roadmap's A12). +target_include_directories(tap_sr_async_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} + ${SampleRateTap_SOURCE_DIR}/tests/support) # D13: the C ABI's version probe, pinned against the shipped shared library # wherever it is built. The test declares the probe itself, so the C header diff --git a/async/tests/test_hardening.cpp b/async/tests/test_hardening.cpp index 2cac6c7..404fa5e 100644 --- a/async/tests/test_hardening.cpp +++ b/async/tests/test_hardening.cpp @@ -20,6 +20,7 @@ #include "support/sine_analysis.h" #include "support/two_clock_sim.h" #include "tap/sr/async/converter.h" +#include "tap_sr_test/expect_rejected.h" namespace { @@ -95,33 +96,33 @@ namespace { { tap::sr::async::config c; c.sample_rate_hz = std::numeric_limits::quiet_NaN(); - EXPECT_THROW(tap::sr::async::converter{c}, std::invalid_argument); + TAP_SR_EXPECT_REJECTED(tap::sr::async::converter{c}, std::invalid_argument); } { tap::sr::async::config c; // anti-image cutoff above input Nyquist c.filter.passband_hz = 23000.0; c.filter.stopband_hz = 47000.0; - EXPECT_THROW(tap::sr::async::converter{c}, std::invalid_argument); + TAP_SR_EXPECT_REJECTED(tap::sr::async::converter{c}, std::invalid_argument); } { tap::sr::async::config c; // eps * 2^64 would overflow int64 in the phase path c.servo.max_deviation_ppm = 400000.0; - EXPECT_THROW(tap::sr::async::converter{c}, std::invalid_argument); + TAP_SR_EXPECT_REJECTED(tap::sr::async::converter{c}, std::invalid_argument); } { tap::sr::async::config c; c.servo.quiet_bandwidth_hz = std::numeric_limits::infinity(); - EXPECT_THROW(tap::sr::async::converter{c}, std::invalid_argument); + TAP_SR_EXPECT_REJECTED(tap::sr::async::converter{c}, std::invalid_argument); } { tap::sr::async::config c; c.fifo_frames = 64; // below the high-watermark capacity requirement - EXPECT_THROW(tap::sr::async::converter{c}, std::invalid_argument); + TAP_SR_EXPECT_REJECTED(tap::sr::async::converter{c}, std::invalid_argument); } // The rate-scaling factory sits exactly on the band-edge sum boundary // (passband + stopband == fs up to rounding); it must keep constructing. - EXPECT_NO_THROW(tap::sr::async::converter{tap::sr::async::config::for_sample_rate(16000.0)}); - EXPECT_NO_THROW(tap::sr::async::converter{tap::sr::async::config::for_sample_rate(44100.0)}); + TAP_SR_EXPECT_ACCEPTED(tap::sr::async::converter{tap::sr::async::config::for_sample_rate(16000.0)}); + TAP_SR_EXPECT_ACCEPTED(tap::sr::async::converter{tap::sr::async::config::for_sample_rate(44100.0)}); } // The family's error model (A3): config::validate is the constructor's @@ -130,17 +131,6 @@ namespace { // A build without exceptions calls it first; this build checks that the // two agree on every rejection the constructor makes. namespace validate_support { - // The constructor's verdict on a config: nullptr, or its exception's text. - template - std::string construction_reason(const tap::sr::async::config& c) { - try { - tap::sr::async::basic_converter asrc{c}; - return {}; - } - catch (const std::invalid_argument& e) { - return e.what(); - } - } std::string reason(const char* why) { return why == nullptr ? std::string{} : std::string{why}; } @@ -233,8 +223,17 @@ namespace { std::size_t rejected = 0; for (std::size_t i = 0; i < cases.size(); ++i) { const std::string by_validate = reason(config::validate(cases[i])); - EXPECT_EQ(by_validate, construction_reason(cases[i])) << "case " << i; - EXPECT_EQ(by_validate, construction_reason(cases[i])) << "case " << i << " (Q15)"; + SCOPED_TRACE("case " + std::to_string(i)); + if (by_validate.empty()) { + TAP_SR_EXPECT_ACCEPTED(tap::sr::async::basic_converter{cases[i]}); + TAP_SR_EXPECT_ACCEPTED(tap::sr::async::basic_converter{cases[i]}); + } + else { + TAP_SR_EXPECT_REJECTED_WITH(tap::sr::async::basic_converter{cases[i]}, std::invalid_argument, + by_validate.c_str()); + TAP_SR_EXPECT_REJECTED_WITH(tap::sr::async::basic_converter{cases[i]}, + std::invalid_argument, by_validate.c_str()); + } rejected += by_validate.empty() ? 0 : 1; } EXPECT_EQ(rejected, 16u) << "every rejection family is exercised"; diff --git a/book/src/part2/icount.md b/book/src/part2/icount.md index fc9c7d1..f326c82 100644 --- a/book/src/part2/icount.md +++ b/book/src/part2/icount.md @@ -308,6 +308,40 @@ plugin-capable `qemu-hexagon` from the pinned QEMU source (linux-user target only, cached thereafter). A measurement gate whose instruments are unpinned is a gate whose meaning can change without a diff. +## Baselines are caller-dependent, unless the engine refuses to be + +The compiler-dependence above has a quieter cousin that the no-exceptions +configuration (the roadmap's A12) dragged into the light. Building the +`rational` workloads with `-fno-exceptions -fno-rtti` moved the M55 float +interpolators +39 % and +53 % and the M33 Q15 rows up to +26 %, with every +checksum unchanged. Nothing in the engine's arithmetic knows about +exceptions, so what moved? + +The disassembly answered. DspTap's dot kernel, compiled alone both ways, +differs in nothing but unwind-table directives. But the ratchet's `main` +inlined the whole engine, and a 2 KB `main` without its EH landing pads +has a different control-flow graph; GCC's register allocator, working over +that graph, left the inner MAC loop's two pointers on the stack. The loop +went from six instructions a tap to ten, the four extra being a load and a +store of each pointer. The engine had not changed. Its *caller* had, and +the baselines had been measuring the caller's register allocation all +along — which is also what rational's PLAN.md had recorded a month earlier +about the channel count: the workloads constructed with a literal, GCC +folded it through the inlined constructor, and a consumer constructing +behind a boundary would pay numbers the ratchet never saw. + +The fix is to make the engine's cost a property of the engine. Its +per-block entries are out of line, so the hot loops are allocated in the +engine's own frame whatever the caller has live; each block runs against a +cursor of locals, so an out-of-line `this` costs nothing per frame; and +the channel count is dispatched to a compile-time constant for the common +counts, as `bridge` already did for its tap count, so the loops that +needed the literal still get it. With exceptions and without, the counts +are now identical to the instruction on every target, and against the old +baselines 17 of 18 M55 rows improved — Q15 by 9 … 18 % — with the one +regression at +1.8 %. The two-sided ratchet caught every step of this, +including the three variants that were measured and declined. + ## What instructions do and do not predict Time to honor the caveat. An instruction count is not a cycle count, and diff --git a/book/src/part7/stage.md b/book/src/part7/stage.md index daf61a7..e9e8d66 100644 --- a/book/src/part7/stage.md +++ b/book/src/part7/stage.md @@ -86,7 +86,31 @@ which is `dot_row` without the finalize — and finalized once: ``` A Q15 output is therefore rounded exactly once, whether it came from one -row or from M of them. Had each branch been dotted and finalized +row or from M of them. + +The `b` those loops read is not the stage. It is a *cursor*: the stream +state and the hot constants, copied into locals when a block begins and +stored back once when it ends. + +```cpp +{{#include ../../../rational/include/tap/sr/rational/stage.h:rational_cursor}} +``` + +Why a header-only engine would copy its own fields into a struct deserves +a paragraph, because the reason is the one most compiler intuitions get +wrong. `process()` is out of line (`TAP_SR_RATIONAL_NOINLINE`), so inside +it `this` is a parameter, and a parameter is memory the compiler must +assume anyone can reach — including `memmove`, which `compact()` calls to +slide a full history line. Every field the frame loop read through `this` +was therefore written back before each possible call and reloaded after +it, around every frame. Measured on the Cortex-M55, that bookkeeping was ++5 … +11 % on the float interpolators. Locals have no such problem: the +compiler owns them, and after inlining the helpers they live in registers. +The decimator's row spans travel in the cursor because its row loop +unrolls and reads them as scalars; the interpolator indexes them by a +runtime phase and reads the members instead, which measured cheaper for +it. Part II's ratchet chapter has the whole story of why the entries are +out of line in the first place. Had each branch been dotted and finalized separately and the results added, a decimator would carry M rounding points and the "DC gain exactly 1 in every format" promise would be off by up to M − 1 LSB. The single rounding point is also what makes the diff --git a/bridge/PLAN.md b/bridge/PLAN.md index f9a8f1c..fe89712 100644 --- a/bridge/PLAN.md +++ b/bridge/PLAN.md @@ -342,6 +342,23 @@ executed (it measures the shipping C++, not a Python re-implementation). not there — deliberately, so the difference is the loop and nothing else. Every existing baseline stayed identical to the instruction. + - **M7f — the per-block entries out of line (A12, 2026-11-05).** + `process`, `pull` and `flush` carry `TAP_SR_BRIDGE_NOINLINE` on every + target, so the walk is register-allocated in the engine's own frame + rather than in whatever the caller has live around it. The family's + exceptions-off configuration (`TAP_SR_NO_EXCEPTIONS_RTTI`, the roadmap's + A12) found the need: with the entries inlined into the ratchet's + `main`, `-fno-exceptions -fno-rtti` read M55 `up_q31_eco` +15.2 % with + the output bits identical — GCC's allocation of a `main` without its + EH landing pads, not the engine (rational's PLAN.md section 6 has the + disassembly-level account; its inner loop lost its pointers to the + stack). Out of line, measured on the M33, M55 and Hexagon in both + configurations: every bridge row within ±0.7 % of its baseline, the two + configurations identical to the instruction (Hexagon's clang had never + inlined them: +0.000 % there, the attribute a no-op). The ceilings did + not move. Unlike the mirrored arm (M7d), this attribute is not gated per + target: it changes nothing where the compiler already kept the entry + out of line and removes a caller dependence everywhere else. - **Hexagon harness isolation (monorepo migration step P.2; re-record, not a lever).** `scripts/icount.py` now runs every Hexagon workload from one fixed path with a fixed `argv[0]` and an empty environment: diff --git a/bridge/include/tap/sr/bridge/converter.h b/bridge/include/tap/sr/bridge/converter.h index 42bd100..76b5122 100644 --- a/bridge/include/tap/sr/bridge/converter.h +++ b/bridge/include/tap/sr/bridge/converter.h @@ -32,6 +32,16 @@ #else #define TAP_SR_BRIDGE_MIRRORED_DOT_ATTR __attribute__((noinline)) #endif +// The per-block entry points (process, pull, flush) stay out of line on every +// target: the walk is then register-allocated in the engine's own frame, so +// its instruction count is a property of the engine, not of what the caller +// has live around it (measured both ways on the M33, M55 and Hexagon; the +// family roadmap's A12, PLAN.md section 6). +#if defined(_MSC_VER) +#define TAP_SR_BRIDGE_NOINLINE __declspec(noinline) +#else +#define TAP_SR_BRIDGE_NOINLINE __attribute__((noinline)) +#endif namespace tap::sr::bridge { @@ -138,7 +148,7 @@ namespace tap::sr::bridge { /// inlined kernels unroll and vectorize against it instead of a /// runtime bound. A custom-taps profile takes the runtime-length /// instantiation (pinned by the custom-profile parity test). - std::size_t process(const S* in, std::size_t in_frames, S* out) noexcept { + TAP_SR_BRIDGE_NOINLINE std::size_t process(const S* in, std::size_t in_frames, S* out) noexcept { const std::size_t taps = m_table.taps(); if (taps == k_taps_economy) { return process_taps(in, in_frames, out); @@ -166,7 +176,7 @@ namespace tap::sr::bridge { /// PopFn must be noexcept (enforced at compile time: pull() is /// noexcept, so a throwing callback could only terminate). template - std::size_t pull(S* out, std::size_t out_frames, PopFn&& pop) noexcept { + TAP_SR_BRIDGE_NOINLINE std::size_t pull(S* out, std::size_t out_frames, PopFn&& pop) noexcept { static_assert(std::is_nothrow_invocable_r_v, "pull() requires a noexcept PopFn: std::size_t(S*, std::size_t) noexcept"); for (std::size_t n = 0; n < out_frames; ++n) { @@ -220,7 +230,7 @@ namespace tap::sr::bridge { /// become ready — every output influenced by real input. Returns the /// frames written (== outputs_for(taps()) beforehand). The engine is /// left mid-stream in the zero-fed state; reset() before reuse. - std::size_t flush(S* out) noexcept { + TAP_SR_BRIDGE_NOINLINE std::size_t flush(S* out) noexcept { const std::size_t zeros = m_table.taps(); std::size_t fed = 0; std::size_t produced = 0; diff --git a/bridge/tests/CMakeLists.txt b/bridge/tests/CMakeLists.txt index dfbbeb6..eea5cd3 100644 --- a/bridge/tests/CMakeLists.txt +++ b/bridge/tests/CMakeLists.txt @@ -40,7 +40,10 @@ add_executable(tap_sr_bridge_tests test_skeleton.cpp) # The tests' own headers (the committed reference vectors) resolve relative # to this directory. -target_include_directories(tap_sr_bridge_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +# The family's test support (tests/support/tap_sr_test/: the rejection +# assertions, with and without exceptions; the roadmap's A12). +target_include_directories(tap_sr_bridge_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} + ${SampleRateTap_SOURCE_DIR}/tests/support) target_link_libraries(tap_sr_bridge_tests PRIVATE tap::sr::bridge tap_sr_async_headers diff --git a/bridge/tests/test_converter.cpp b/bridge/tests/test_converter.cpp index 8c684f1..b8b40fa 100644 --- a/bridge/tests/test_converter.cpp +++ b/bridge/tests/test_converter.cpp @@ -22,6 +22,7 @@ #include "reference/reference_vectors.h" #include "tap/dsp/analysis/sine_analysis.h" #include "tap/sr/bridge/converter.h" +#include "tap_sr_test/expect_rejected.h" namespace { @@ -456,7 +457,7 @@ namespace { conv c(1); EXPECT_NEAR(c.latency_input_frames(), 58.0 / 2.0, 0.51); EXPECT_EQ(c.channels(), 1u); - EXPECT_THROW(conv(0), std::invalid_argument); + TAP_SR_EXPECT_REJECTED(conv(0), std::invalid_argument); } // The family's error model (A3): validate() is the constructor's rejection @@ -472,28 +473,19 @@ namespace { {profile::super_economy(), profile::economy(), profile::balanced(), profile::transparent()}) { EXPECT_EQ(down::validate(1, p), nullptr); EXPECT_EQ(up::validate(2, p), nullptr); - EXPECT_NO_THROW(down(1, p)); + TAP_SR_EXPECT_ACCEPTED(down(1, p)); } - const auto rejection = [](auto make) -> std::string { - try { - make(); - return {}; - } - catch (const std::invalid_argument& e) { - return e.what(); - } - }; EXPECT_STREQ(down::validate(0), "tap::sr::bridge::basic_converter: channels == 0"); - EXPECT_EQ(rejection([] { down c(0); }), down::validate(0)); + TAP_SR_EXPECT_REJECTED_WITH(down(0), std::invalid_argument, down::validate(0)); profile bad = profile::economy(); bad.passband_hz = 23000.0; // above the down direction's stopband edge EXPECT_STREQ(down::validate(1, bad), "tap::sr::bridge::design_prototype: bad profile"); - EXPECT_EQ(rejection([&] { down c(1, bad); }), down::validate(1, bad)); + TAP_SR_EXPECT_REJECTED_WITH(down(1, bad), std::invalid_argument, down::validate(1, bad)); EXPECT_EQ(up::validate(1, bad), nullptr) << "23 kHz is below the up direction's 24 kHz edge"; profile q = profile::economy(); q.stopband_atten_db = -1.0; - EXPECT_EQ(rejection([&] { up c(1, q); }), up::validate(1, q)); EXPECT_NE(up::validate(1, q), nullptr); + TAP_SR_EXPECT_REJECTED_WITH(up(1, q), std::invalid_argument, up::validate(1, q)); } } // namespace diff --git a/bridge/tests/test_design.cpp b/bridge/tests/test_design.cpp index 0316677..a622f22 100644 --- a/bridge/tests/test_design.cpp +++ b/bridge/tests/test_design.cpp @@ -17,6 +17,7 @@ #include #include "tap/sr/bridge/design.h" +#include "tap_sr_test/expect_rejected.h" namespace { @@ -121,14 +122,14 @@ namespace { TEST(Design, BadProfilesThrow) { profile p = profile::economy(); p.passband_hz = 23000.0; // above the down direction's stopband edge - EXPECT_THROW((design_prototype(p)), std::invalid_argument); + TAP_SR_EXPECT_REJECTED((design_prototype(p)), std::invalid_argument); profile q = profile::economy(); q.stopband_atten_db = -1.0; - EXPECT_THROW((design_prototype(q)), std::invalid_argument); + TAP_SR_EXPECT_REJECTED((design_prototype(q)), std::invalid_argument); // The edge scales with the pair, so the same profile is bad at every // rate scale (23 kHz at K = 0 is 46 kHz at K = 1, still above 44.1). - EXPECT_THROW((design_prototype(p)), std::invalid_argument); - EXPECT_THROW((design_prototype(p)), std::invalid_argument); + TAP_SR_EXPECT_REJECTED((design_prototype(p)), std::invalid_argument); + TAP_SR_EXPECT_REJECTED((design_prototype(p)), std::invalid_argument); // The same verdicts as the noexcept precheck the converter composes (A3). using tap::sr::bridge::validate_profile; static_assert(noexcept(validate_profile(p))); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a304c13..83e8ba6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -64,6 +64,7 @@ is this checklist, not an opinion. | P11 | C ABI: one error model, extension rule, compat test, version probe | **open** (C3) | **open** (C3) | **open** (C3) | | P12 | Versioned substrate pin asserted at compile time | ✓ (A9) | ✓ (A9) | ✓ (A9) | | P13 | Changelog, semver and deprecation statement reconciled with D7 | **open** (C3) | **open** (C3) | **open** (C3) | +| P14 | Every battery passes under `-fno-exceptions -fno-rtti` on M33, M55 and Hexagon, gated | ✓ (A12) | ✓ (A12) | ✓ (A12) | ## 3. Block A (weeks 1–2): contracts and hygiene @@ -142,6 +143,35 @@ gated on shipping. USB audio firmware that enumerates as a UAC2 asynchronous-mode device (feedback endpoint) on its own crystal, the bench's independent clock (Q1), which is not in the tree today. +- **A12. Every battery without exceptions or RTTI** (added 2026-11-04, Q5's answer: a + current project builds all three engines with `-fno-exceptions -fno-rtti` on the M33, M55 + and Hexagon). A3 made the engines and C ABIs build that way, but no test ran there and + nothing was built with `-fno-rtti`. In two PRs. *The substrate (tap/DspTap#63): + `TAP_DSP_NO_EXCEPTIONS_RTTI`, DspTap's whole battery so on Linux, the M33 and the M55. + This tree: `TAP_SR_NO_EXCEPTIONS_RTTI` builds every target so (an option, since + `CMAKE_CXX_FLAGS` would replace the cross toolchains' flags); the rejection tests go + through `tests/support/tap_sr_test/expect_rejected.h` (`EXPECT_THROW` with exceptions, + a death test on `std::terminate()` without, `validate()`'s text pinned either way); the + hosted no-exceptions job runs every battery, examples and C ABIs; the nine QEMU legs + run again in that configuration. The measurement found the ratchet's counts were not + the engines': built the new way with the engines inlined into the workloads' `main` as + before, `rational`'s M55 float interpolators read +39 … +53 % and `bridge`'s M55 Q31 + row +15 % with every checksum unchanged — GCC's register allocation of a `main` whose + control flow had lost its EH landing pads (the inner MAC loop's two pointers went to + the stack, 6 instructions a tap becoming 10), not the engines. So the same PR makes the + hot path the engine's own: `bridge`'s and `rational`'s per-block entries are out of + line (`TAP_SR_BRIDGE_NOINLINE`, `TAP_SR_RATIONAL_NOINLINE`), `rational` runs each + block against a cursor of locals over one planar history and dispatches the channel + count to a compile-time constant for one and two channels (the lever its PLAN.md + section 6 had named). Measured on the M33, M55, Hexagon and host in both + configurations: the counts identical to the instruction with and without exceptions + on every row of every engine; against the previous baselines `bridge` and `async` + unchanged (within ±0.7 %), `rational` 17 of 18 M55 rows improved (Q15 −9.5 … −17.8 %, + the chain −12.9 %, the one row up +1.8 %), the M33 to −16 %, Hexagon to −9 %, the host + stereo rows −5 %; `rational`'s baselines re-recorded on every target and its ceilings + on the three (engine `.text` +25 … +50 % for the three channel instantiations, the M33 + call stacks +64 … +104 B for the out-of-line frame), with the reason in + `rational/PLAN.md` section 6.* ## 4. Block B (weeks 3–8): the hardware campaign @@ -205,12 +235,14 @@ writes the tooling and reads the results. `main` protected; a manual workflow that re-executes the notebooks and diffs the figure digests as part of the release checklist. The 1.0 freeze covers `bridge`, `rational` and the existing `async` surface; the `bridge` C ABI stays at K = 0 and may grow additively - as already promised. + as already promised. The 1.0 freeze also covers the configuration + `-fno-exceptions -fno-rtti` (P14, A12): every battery passes under it on the M33, M55 and + Hexagon, gated in CI, and a change that needs either feature in a header is a break. ## 6. Verdict rule -`bridge` and `rational` are production-ready when P1–P7 and P9–P13 are green: Block A -closes P3–P6, P9, P10, P12; B2 closes P7; C3 closes P11 and P13. `async` additionally needs +`bridge` and `rational` are production-ready when P1–P7 and P9–P14 are green: Block A +closes P3–P6, P9, P10, P12, P14; B2 closes P7; C3 closes P11 and P13. `async` additionally needs P8 from B1. On this sequencing, `bridge` and `rational` reach the bar at the end of Block C (not Block B: P11 and P13 are release engineering), and `async` reaches it at the same point, provided B1's numbers are inside the README's claims; with the bench running twice @@ -248,6 +280,10 @@ confirm them. - **Q5. `-fno-exceptions`.** Does any consumer build with it today? **Decided:** probably our own Pico 2 firmware, the SDK's default (the maintainer's best recollection, not confirmed); A3 landed regardless (#81). + **Confirmed 2026-11-04:** no consumer builds so today, and the Pico firmwares force + exceptions on; but a current project has a hard requirement for `-fno-exceptions + -fno-rtti` on the M33, M55 and Hexagon, with all three engines. A12 makes that a gated + configuration and C3 freezes it (P14). - **Q6. The host ratchet.** A hard gate on callgrind `Ir` (deterministic enough on the runners), or an unguarded trend with the channel-parallel path documented as ungated? **Decided:** a hard gate, two-sided at ±3 % (A8). diff --git a/rational/CLAUDE.md b/rational/CLAUDE.md index aadc709..bdbefac 100644 --- a/rational/CLAUDE.md +++ b/rational/CLAUDE.md @@ -78,6 +78,15 @@ symmetry-halved table were declined on their numbers. interpolator's centre phase is one tap, a copy); a decimator runs the M-branch commutator, its nonzero branches summed under one finalize (`tap::dsp::accumulate_row`). MACs per output are the design's nonzero counts; the structural zeros are never multiplied (R4). +- **The hot path belongs to the engine, not its caller (A12).** `process`, `pull` and `flush` + are out of line (`TAP_SR_RATIONAL_NOINLINE`), each runs its block against a `cursor` (the + stream state and the hot constants as locals, `open()` / `close()`), and dispatches the + channel count as a compile-time constant for one and two channels. Inside the block loops a + member read is a reload per frame — an out-of-line `this` is reachable from `compact()`'s + `memmove` — so the state and the decimator's row spans travel in the cursor; the + interpolator's spans, indexed by a runtime phase, stay member reads because that measured + cheaper (PLAN.md section 6, the A12 lever). The instruction counts are identical with and + without exceptions; the ratchet runs both configurations against one set of baselines. - **Correctness before optimization.** Scipy golden vectors, exhaustive phase sweeps, `decimate.h` as a second golden at ↓2 / ↓3 / ↓6, and the 182-row coverage matrix test gate every optimization that follows (PLAN.md section 5). Regenerate the vectors only when the diff --git a/rational/PLAN.md b/rational/PLAN.md index 5fdf424..09d9581 100644 --- a/rational/PLAN.md +++ b/rational/PLAN.md @@ -1057,6 +1057,48 @@ against `bridge`'s 147 / 160 phases, while on the M55 the reversed Q15 dot reads its row through a gather, slower than the contiguous load. A memory-bound consumer can reopen it with these numbers. +**The caller-independent hot path (A12, 2026-11-05): the lever the A3 +finding names, pulled.** It surfaced in the family's exceptions-off +configuration (`TAP_SR_NO_EXCEPTIONS_RTTI`, the roadmap's A12). With the +engine inlined into the ratchet's `main` as before, `-fno-exceptions +-fno-rtti` moved the M55 float interpolators +39 … +53 % and the M33 Q15 +rows up to +26 %, the output bits identical (the checksums). Read from the +disassembly: DspTap's dot kernel compiles to the same instructions either +way (the kernel alone, both ways, differs in nothing but unwind-table +directives); inlined into a 2 KB `main` whose control flow differed only +by its EH landing pads, GCC's register allocator left the inner MAC loop's +two pointers on the stack, 6 instructions a tap becoming 10, the four extra +being the pointers' load and store. Outlining the per-block entries alone +(`process`, `pull`, `flush` behind `TAP_SR_RATIONAL_NOINLINE`) made the two +configurations identical to the instruction but cost the M55 float +interpolators +5 … +11 %, for two further reasons an out-of-line `this` +creates: every field the loops read was written back and reloaded around +each frame, since `this` is reachable from `compact()`'s `memmove`; and +GCC's `-fversion-loops-for-strides`, on at `-O3`, versioned the +per-channel append for a stride of one. The shape that holds is in +`stage.h`: each block runs against a `cursor` (the stream state and the +hot constants as locals, loaded by `open()`, stored once by `close()`; the +decimator's row spans travel in it, the interpolator reads its spans by +phase from the members — each the cheaper, measured), the history is one +planar allocation (`m_hist`, channels × `k_lines` lines of `m_hist_cap`), +and the entries dispatch the channel count as a compile-time constant for +one and two channels (`process_block`, as `bridge`'s `process()` does +for its tap count). Against the baselines before it, M55 Release: every +row within the gate and 17 of 18 improved — Q15 −9.5 … −17.8 %, the +down2·down2 chain −12.9 %, the float interpolators within +0.65 %, ↓3 +float −8.4 %, ↓2 float +1.8 % (the one row up; its `transparent` row ++0.9 %) — and identical to the instruction with and without exceptions. +The M33, Hexagon and host rows are the baselines re-recorded with this +change. The footprint lost its `P ch sizeof(std::vector)` term (the +README's table). Measured and declined on the way, on the M55: `emit()` +out of line per output (the configurations identical, the float +interpolators +4.8 %); the cursor with the entries inlined (floats +−1.5 … −2.8 %, but the chain +13.4 % without exceptions and the two +configurations apart again: the caller's register pressure); and +`-fno-version-loops-for-strides` on the outlined entries (1.5 points: the +versioning was not the bulk). The engine's cost is now a property of the +engine; the ratchet's `main` no longer measures its own register allocation. + **Order and gating.** M0 lands in DspTap and the family bumps its pin (`../CLAUDE.md`, "Substrate discipline"); M1–M6 are done; the 2.2 follow-up landed before M4, so nothing waits for it. Every milestone's PR diff --git a/rational/README.md b/rational/README.md index 4172cbe..2cb4a74 100644 --- a/rational/README.md +++ b/rational/README.md @@ -238,33 +238,33 @@ Executed instructions per fixed workload (`rational/bench/icount/`), measured un | Workload | Cortex-M33 | Cortex-M55 | Hexagon | |---|---:|---:|---:| -| `callmax_down2_float` | 116,628 | 8,428 | 23,891 | -| `callmax_down2_q15` | 7,383 | 6,198 | 4,427 | -| `callmax_up2_float` | 231,692 | 15,287 | 45,911 | -| `callmax_up2_q15` | 14,798 | 11,208 | 6,941 | -| `construct_q15_eco` | 26,810,642 | 1,567,568 | 8,341,715 | -| `down2_down2_q15_eco` | 62,511,767 | 28,181,134 | 22,263,063 | -| `down2_float_eco` | 366,459,868 | 24,433,817 | 72,735,466 | -| `down2_float_tr` | 955,279,507 | 47,853,721 | 174,664,587 | -| `down2_q15_eco` | 47,494,332 | 19,807,076 | 18,558,597 | -| `down3_float_eco` | 476,578,411 | 31,908,807 | 90,346,064 | -| `down3_q15_eco` | 51,981,049 | 21,247,202 | 19,802,949 | -| `ratio23_float_eco` | 611,616,073 | 33,245,961 | 127,126,391 | -| `ratio23_q15_eco` | 52,786,483 | 18,797,362 | 20,006,732 | -| `up2_float_eco` | 732,659,056 | 43,442,641 | 137,930,790 | -| `up2_float_tr` | 1,961,674,664 | 89,902,184 | 340,655,838 | -| `up2_q15_eco` | 67,354,970 | 34,150,666 | 28,133,327 | -| `up3_float_eco` | 1,407,620,233 | 75,331,049 | 254,904,021 | -| `up3_q15_eco` | 92,695,852 | 51,156,233 | 39,794,001 | +| `callmax_down2_float` | 118,191 | 8,131 | 23,943 | +| `callmax_down2_q15` | 7,224 | 5,735 | 4,268 | +| `callmax_up2_float` | 231,726 | 14,175 | 44,683 | +| `callmax_up2_q15` | 12,391 | 9,607 | 6,304 | +| `construct_q15_eco` | 26,795,397 | 1,563,967 | 8,330,417 | +| `down2_down2_q15_eco` | 55,160,639 | 24,552,975 | 22,071,551 | +| `down2_float_eco` | 373,681,206 | 24,882,828 | 72,885,988 | +| `down2_float_tr` | 970,180,513 | 48,302,560 | 174,817,755 | +| `down2_q15_eco` | 47,081,268 | 17,059,962 | 18,048,480 | +| `down3_float_eco` | 467,685,055 | 29,237,795 | 90,250,094 | +| `down3_q15_eco` | 49,852,601 | 18,116,226 | 19,549,385 | +| `ratio23_float_eco` | 591,237,741 | 33,460,544 | 125,408,437 | +| `ratio23_q15_eco` | 49,645,786 | 15,451,527 | 18,689,641 | +| `up2_float_eco` | 712,003,791 | 43,649,236 | 134,224,931 | +| `up2_float_tr` | 1,902,619,470 | 90,108,510 | 336,949,711 | +| `up2_q15_eco` | 63,617,031 | 29,508,255 | 26,236,602 | +| `up3_float_eco` | 1,366,708,994 | 75,348,550 | 249,845,161 | +| `up3_q15_eco` | 87,322,867 | 44,313,203 | 38,272,880 | On the host, the float converter in 128-frame blocks at 2 and 12 channels (`rational/bench/icount/host_main.cpp`): instructions executed by the streaming loop alone, counted under callgrind on x86-64 with GCC 13 (`scripts/icount.py --target host`), gated the same way: | Workload | x86-64 | |---|---:| -| `down2_float_12ch` | 161,054,642 | -| `down2_float_2ch` | 29,983,981 | -| `up2_float_12ch` | 288,223,796 | -| `up2_float_2ch` | 57,296,876 | +| `down2_float_12ch` | 161,390,269 | +| `down2_float_2ch` | 28,551,355 | +| `up2_float_12ch` | 282,912,211 | +| `up2_float_2ch` | 54,413,191 | ## Footprint @@ -276,9 +276,8 @@ coefficient size, `s` the sample size, `ch` channels): ```text held = R T c rows - + (T + 64) P ch s history, 64 frames of append slack + + (T + 64) P ch s history: one planar allocation, P x ch lines, 64 frames of append slack + 16 ch s pull()'s pop scratch - + P ch sizeof(std::vector) peak = max(design, 8 N + held) the double design lives to the end of the constructor ``` @@ -290,29 +289,30 @@ which C++ leaves unspecified, so the test reports it and does not pin it. Stages | stage | N | f32 held | f32 peak | Q15 held | Q15 peak | Q31 held | Q31 peak | |---|---:|---:|---:|---:|---:|---:|---:| -| `up_2` | 43 | 1,040 | 1,384 | 544 | 888 | 1,040 | 1,384 | -| `down_2` | 43 | 1,776 | 2,120 | 936 | 1,280 | 1,776 | 2,120 | -| `up_3` | 65 | 1,128 | 1,648 | 588 | 1,108 | 1,128 | 1,648 | -| `down_3` | 65 | 2,600 | 3,120 | 1,372 | 1,892 | 2,600 | 3,120 | -| `up_6` | 143 | 1,456 | 2,600 | 752 | 1,896 | 1,456 | 2,600 | -| `down_6` | 143 | 5,216 | 6,360 | 2,752 | 3,896 | 5,216 | 6,360 | -| `up_8` | 191 | 1,648 | 3,176 | 848 | 2,376 | 1,648 | 3,176 | -| `down_8` | 191 | 6,912 | 8,440 | 3,648 | 5,176 | 6,912 | 8,440 | -| `ratio_3_2` | 65 | 1,128 | 1,648 | 588 | 1,108 | 1,128 | 1,648 | -| `ratio_2_3` | 65 | 1,216 | 1,736 | 632 | 1,246 | 1,216 | 1,736 | -| `ratio_4_3` | 87 | 1,216 | 1,912 | 632 | 1,328 | 1,216 | 1,912 | -| `ratio_3_4` | 87 | 1,268 | 1,964 | 658 | 1,392 | 1,268 | 1,964 | -| `ratio_8_3` | 191 | 1,648 | 3,176 | 848 | 2,376 | 1,648 | 3,176 | -| `ratio_3_8` | 191 | 1,968 | 3,496 | 1,008 | 3,064 | 1,968 | 3,576 | - -Across the profiles a stage grows with its pins: `up_2` f32 stereo holds 976 / 1,040 / -1,104 / 1,680 bytes at `super_economy` / `economy` / `balanced` / `transparent`, and -`down_2` 1,680 / 1,776 / 1,872 / 2,736. The named chains hold 3.5 KB (`up_2_up_3`) to -12.8 KB (`up_2_up_8_up_3`) at `economy`, f32 stereo, and up to 14.3 KB at +| `up_2` | 43 | 992 | 1,336 | 496 | 840 | 992 | 1,336 | +| `down_2` | 43 | 1,680 | 2,024 | 840 | 1,184 | 1,680 | 2,024 | +| `up_3` | 65 | 1,080 | 1,600 | 540 | 1,060 | 1,080 | 1,600 | +| `down_3` | 65 | 2,456 | 2,976 | 1,228 | 1,748 | 2,456 | 2,976 | +| `up_6` | 143 | 1,408 | 2,552 | 704 | 1,864 | 1,408 | 2,552 | +| `down_6` | 143 | 4,928 | 6,072 | 2,464 | 3,608 | 4,928 | 6,072 | +| `up_8` | 191 | 1,600 | 3,128 | 800 | 2,344 | 1,600 | 3,128 | +| `down_8` | 191 | 6,528 | 8,056 | 3,264 | 4,792 | 6,528 | 8,056 | +| `ratio_3_2` | 65 | 1,080 | 1,600 | 540 | 1,060 | 1,080 | 1,600 | +| `ratio_2_3` | 65 | 1,168 | 1,688 | 584 | 1,246 | 1,168 | 1,688 | +| `ratio_4_3` | 87 | 1,168 | 1,864 | 584 | 1,280 | 1,168 | 1,864 | +| `ratio_3_4` | 87 | 1,220 | 1,916 | 610 | 1,392 | 1,220 | 1,916 | +| `ratio_8_3` | 191 | 1,600 | 3,128 | 800 | 2,344 | 1,600 | 3,128 | +| `ratio_3_8` | 191 | 1,920 | 3,448 | 960 | 3,064 | 1,920 | 3,576 | + +Across the profiles a stage grows with its pins: `up_2` f32 stereo holds 928 / 992 / +1,056 / 1,632 bytes at `super_economy` / `economy` / `balanced` / `transparent`, and +`down_2` 1,584 / 1,680 / 1,776 / 2,640. The named chains hold 3.4 KB (`up_2_up_3`) to +12.7 KB (`up_2_up_8_up_3`) at `economy`, f32 stereo, and up to 14.1 KB at `transparent`. -The figures are bytes on a 64-bit host; a 32-bit target holds `sizeof(std::vector)` -(12 instead of 24) less per history vector and nothing else changes. +The figures are bytes, and the formula has no pointer-sized term (the history is one +allocation since the roadmap's A12; before it, each channel's each line was a vector of +its own, `P ch sizeof(std::vector)` more), so a 32-bit target holds the same. `tests/test_footprint.cpp` pins the stage formulas, held and peak, for all 14 ratios of the vocabulary, every profile and format, and every named chain's held bytes, against DspTap's heap meter (`tap/dsp/analysis/heap_meter.h`) exactly, on the hosted legs @@ -347,24 +347,24 @@ same PR, with the reason. | target | format | engine `.text` (bytes) | ceiling | engine `.rodata` (bytes) | ceiling | image (bytes) | ceiling | |---|---|--:|--:|--:|--:|--:|--:| -| m33 | f32 | 8,964 | 9,280 | 996 | 1,088 | 43,497 | 44,864 | -| m33 | q15 | 11,044 | 11,392 | 996 | 1,088 | 45,777 | 47,168 | -| m33 | q31 | 10,520 | 10,880 | 996 | 1,088 | 45,249 | 46,656 | -| m55 | f32 | 7,900 | 8,192 | 996 | 1,088 | 43,657 | 44,992 | -| m55 | q15 | 9,708 | 10,048 | 996 | 1,088 | 45,769 | 47,168 | -| m55 | q31 | 9,456 | 9,792 | 996 | 1,088 | 45,521 | 46,912 | -| hexagon | f32 | 19,384 | 19,968 | 428 | 448 | 296,776 | 305,728 | -| hexagon | q15 | 21,304 | 21,952 | 428 | 448 | 299,304 | 308,288 | -| hexagon | q31 | 20,096 | 20,736 | 428 | 448 | 298,152 | 307,136 | +| m33 | f32 | 10,948 | 11,328 | 996 | 1,088 | 45,481 | 46,848 | +| m33 | q15 | 13,152 | 13,568 | 996 | 1,088 | 47,897 | 49,344 | +| m33 | q31 | 12,680 | 13,120 | 996 | 1,088 | 47,417 | 48,896 | +| m55 | f32 | 10,088 | 10,432 | 996 | 1,088 | 45,833 | 47,232 | +| m55 | q15 | 12,292 | 12,672 | 996 | 1,088 | 48,345 | 49,856 | +| m55 | q31 | 11,664 | 12,032 | 996 | 1,088 | 47,721 | 49,216 | +| hexagon | f32 | 25,704 | 26,496 | 428 | 448 | 303,144 | 312,256 | +| hexagon | q15 | 32,496 | 33,472 | 428 | 448 | 310,600 | 319,936 | +| hexagon | q31 | 31,596 | 32,576 | 428 | 448 | 309,704 | 319,040 | | M33 call | format | static worst path (bytes) | ceiling | measured high-water (bytes) | ceiling | |---|---|--:|--:|--:|--:| -| `up` | f32 | 128 | 136 | 128 | 136 | -| `up` | q15 | 128 | 136 | 112 | 120 | -| `up` | q31 | 96 | 104 | 96 | 104 | -| `down` | f32 | 104 | 112 | 104 | 112 | -| `down` | q15 | 104 | 112 | 88 | 96 | -| `down` | q31 | 88 | 96 | 88 | 96 | +| `up` | f32 | 192 | 200 | 192 | 200 | +| `up` | q15 | 192 | 200 | 176 | 184 | +| `up` | q31 | 200 | 208 | 200 | 208 | +| `down` | f32 | 208 | 216 | 208 | 216 | +| `down` | q15 | 200 | 208 | 184 | 192 | +| `down` | q31 | 184 | 192 | 184 | 192 | ## Non-finite input and denormals diff --git a/rational/bench/baselines.json b/rational/bench/baselines.json index dee7d6d..cb3e3e4 100644 --- a/rational/bench/baselines.json +++ b/rational/bench/baselines.json @@ -1,68 +1,68 @@ { "hexagon": { - "callmax_down2_float": 23891, - "callmax_down2_q15": 4427, - "callmax_up2_float": 45911, - "callmax_up2_q15": 6941, - "construct_q15_eco": 8341715, - "down2_down2_q15_eco": 22263063, - "down2_float_eco": 72735466, - "down2_float_tr": 174664587, - "down2_q15_eco": 18558597, - "down3_float_eco": 90346064, - "down3_q15_eco": 19802949, - "ratio23_float_eco": 127126391, - "ratio23_q15_eco": 20006732, - "up2_float_eco": 137930790, - "up2_float_tr": 340655838, - "up2_q15_eco": 28133327, - "up3_float_eco": 254904021, - "up3_q15_eco": 39794001 + "callmax_down2_float": 23943, + "callmax_down2_q15": 4268, + "callmax_up2_float": 44683, + "callmax_up2_q15": 6304, + "construct_q15_eco": 8330417, + "down2_down2_q15_eco": 22071551, + "down2_float_eco": 72885988, + "down2_float_tr": 174817755, + "down2_q15_eco": 18048480, + "down3_float_eco": 90250094, + "down3_q15_eco": 19549385, + "ratio23_float_eco": 125408437, + "ratio23_q15_eco": 18689641, + "up2_float_eco": 134224931, + "up2_float_tr": 336949711, + "up2_q15_eco": 26236602, + "up3_float_eco": 249845161, + "up3_q15_eco": 38272880 }, "host": { - "down2_float_12ch": 161054642, - "down2_float_2ch": 29983981, - "up2_float_12ch": 288223796, - "up2_float_2ch": 57296876 + "down2_float_12ch": 161390269, + "down2_float_2ch": 28551355, + "up2_float_12ch": 282912211, + "up2_float_2ch": 54413191 }, "m33": { - "callmax_down2_float": 116628, - "callmax_down2_q15": 7383, - "callmax_up2_float": 231692, - "callmax_up2_q15": 14798, - "construct_q15_eco": 26810642, - "down2_down2_q15_eco": 62511767, - "down2_float_eco": 366459868, - "down2_float_tr": 955279507, - "down2_q15_eco": 47494332, - "down3_float_eco": 476578411, - "down3_q15_eco": 51981049, - "ratio23_float_eco": 611616073, - "ratio23_q15_eco": 52786483, - "up2_float_eco": 732659056, - "up2_float_tr": 1961674664, - "up2_q15_eco": 67354970, - "up3_float_eco": 1407620233, - "up3_q15_eco": 92695852 + "callmax_down2_float": 118191, + "callmax_down2_q15": 7224, + "callmax_up2_float": 231726, + "callmax_up2_q15": 12391, + "construct_q15_eco": 26795397, + "down2_down2_q15_eco": 55160639, + "down2_float_eco": 373681206, + "down2_float_tr": 970180513, + "down2_q15_eco": 47081268, + "down3_float_eco": 467685055, + "down3_q15_eco": 49852601, + "ratio23_float_eco": 591237741, + "ratio23_q15_eco": 49645786, + "up2_float_eco": 712003791, + "up2_float_tr": 1902619470, + "up2_q15_eco": 63617031, + "up3_float_eco": 1366708994, + "up3_q15_eco": 87322867 }, "m55": { - "callmax_down2_float": 8428, - "callmax_down2_q15": 6198, - "callmax_up2_float": 15287, - "callmax_up2_q15": 11208, - "construct_q15_eco": 1567568, - "down2_down2_q15_eco": 28181134, - "down2_float_eco": 24433817, - "down2_float_tr": 47853721, - "down2_q15_eco": 19807076, - "down3_float_eco": 31908807, - "down3_q15_eco": 21247202, - "ratio23_float_eco": 33245961, - "ratio23_q15_eco": 18797362, - "up2_float_eco": 43442641, - "up2_float_tr": 89902184, - "up2_q15_eco": 34150666, - "up3_float_eco": 75331049, - "up3_q15_eco": 51156233 + "callmax_down2_float": 8131, + "callmax_down2_q15": 5735, + "callmax_up2_float": 14175, + "callmax_up2_q15": 9607, + "construct_q15_eco": 1563967, + "down2_down2_q15_eco": 24552975, + "down2_float_eco": 24882828, + "down2_float_tr": 48302560, + "down2_q15_eco": 17059962, + "down3_float_eco": 29237795, + "down3_q15_eco": 18116226, + "ratio23_float_eco": 33460544, + "ratio23_q15_eco": 15451527, + "up2_float_eco": 43649236, + "up2_float_tr": 90108510, + "up2_q15_eco": 29508255, + "up3_float_eco": 75348550, + "up3_q15_eco": 44313203 } } diff --git a/rational/bench/ceilings.json b/rational/bench/ceilings.json index 84530e5..4443ac1 100644 --- a/rational/bench/ceilings.json +++ b/rational/bench/ceilings.json @@ -7,12 +7,12 @@ "recorded": 428 }, "engine_text": { - "ceiling": 19968, - "recorded": 19384 + "ceiling": 26496, + "recorded": 25704 }, "image": { - "ceiling": 305728, - "recorded": 296776 + "ceiling": 312256, + "recorded": 303144 } }, "q15": { @@ -21,12 +21,12 @@ "recorded": 428 }, "engine_text": { - "ceiling": 21952, - "recorded": 21304 + "ceiling": 33472, + "recorded": 32496 }, "image": { - "ceiling": 308288, - "recorded": 299304 + "ceiling": 319936, + "recorded": 310600 } }, "q31": { @@ -35,12 +35,12 @@ "recorded": 428 }, "engine_text": { - "ceiling": 20736, - "recorded": 20096 + "ceiling": 32576, + "recorded": 31596 }, "image": { - "ceiling": 307136, - "recorded": 298152 + "ceiling": 319040, + "recorded": 309704 } } } @@ -53,12 +53,12 @@ "recorded": 996 }, "engine_text": { - "ceiling": 9280, - "recorded": 8964 + "ceiling": 11328, + "recorded": 10948 }, "image": { - "ceiling": 44864, - "recorded": 43497 + "ceiling": 46848, + "recorded": 45481 } }, "q15": { @@ -67,12 +67,12 @@ "recorded": 996 }, "engine_text": { - "ceiling": 11392, - "recorded": 11044 + "ceiling": 13568, + "recorded": 13152 }, "image": { - "ceiling": 47168, - "recorded": 45777 + "ceiling": 49344, + "recorded": 47897 } }, "q31": { @@ -81,12 +81,12 @@ "recorded": 996 }, "engine_text": { - "ceiling": 10880, - "recorded": 10520 + "ceiling": 13120, + "recorded": 12680 }, "image": { - "ceiling": 46656, - "recorded": 45249 + "ceiling": 48896, + "recorded": 47417 } } }, @@ -94,66 +94,66 @@ "f32": { "down": { "measured": { - "ceiling": 112, - "recorded": 104 + "ceiling": 216, + "recorded": 208 }, "static": { - "ceiling": 112, - "recorded": 104 + "ceiling": 216, + "recorded": 208 } }, "up": { "measured": { - "ceiling": 136, - "recorded": 128 + "ceiling": 200, + "recorded": 192 }, "static": { - "ceiling": 136, - "recorded": 128 + "ceiling": 200, + "recorded": 192 } } }, "q15": { "down": { "measured": { - "ceiling": 96, - "recorded": 88 + "ceiling": 192, + "recorded": 184 }, "static": { - "ceiling": 112, - "recorded": 104 + "ceiling": 208, + "recorded": 200 } }, "up": { "measured": { - "ceiling": 120, - "recorded": 112 + "ceiling": 184, + "recorded": 176 }, "static": { - "ceiling": 136, - "recorded": 128 + "ceiling": 200, + "recorded": 192 } } }, "q31": { "down": { "measured": { - "ceiling": 96, - "recorded": 88 + "ceiling": 192, + "recorded": 184 }, "static": { - "ceiling": 96, - "recorded": 88 + "ceiling": 192, + "recorded": 184 } }, "up": { "measured": { - "ceiling": 104, - "recorded": 96 + "ceiling": 208, + "recorded": 200 }, "static": { - "ceiling": 104, - "recorded": 96 + "ceiling": 208, + "recorded": 200 } } } @@ -167,12 +167,12 @@ "recorded": 996 }, "engine_text": { - "ceiling": 8192, - "recorded": 7900 + "ceiling": 10432, + "recorded": 10088 }, "image": { - "ceiling": 44992, - "recorded": 43657 + "ceiling": 47232, + "recorded": 45833 } }, "q15": { @@ -181,12 +181,12 @@ "recorded": 996 }, "engine_text": { - "ceiling": 10048, - "recorded": 9708 + "ceiling": 12672, + "recorded": 12292 }, "image": { - "ceiling": 47168, - "recorded": 45769 + "ceiling": 49856, + "recorded": 48345 } }, "q31": { @@ -195,12 +195,12 @@ "recorded": 996 }, "engine_text": { - "ceiling": 9792, - "recorded": 9456 + "ceiling": 12032, + "recorded": 11664 }, "image": { - "ceiling": 46912, - "recorded": 45521 + "ceiling": 49216, + "recorded": 47721 } } } diff --git a/rational/include/tap/sr/rational/stage.h b/rational/include/tap/sr/rational/stage.h index 2492761..8f3f50c 100644 --- a/rational/include/tap/sr/rational/stage.h +++ b/rational/include/tap/sr/rational/stage.h @@ -109,6 +109,18 @@ #include "tap/sr/rational/design.h" #include "tap/sr/rational/ratio.h" +// The per-block entry points (process, pull, flush) stay out of line: the +// hot loop is then register-allocated in the engine's own frame, so its +// instruction count is a property of the engine and not of whatever the +// caller happens to have live around it (measured both ways on the M33, M55 +// and Hexagon, the ratchet's record in PLAN.md section 6: inlined into a +// large caller the inner MAC loop lost its two pointers to the stack). +#if defined(_MSC_VER) +#define TAP_SR_RATIONAL_NOINLINE __declspec(noinline) +#else +#define TAP_SR_RATIONAL_NOINLINE __attribute__((noinline)) +#endif + namespace tap::sr::rational { // ANCHOR: rational_schedule @@ -250,45 +262,65 @@ namespace tap::sr::rational { m_taps = h.size(); build_table(h); m_hist_cap = m_row_len + k_hist_slack; - m_hist.resize(channels * k_lines); - for (auto& line : m_hist) { - line.assign(m_hist_cap, tap::dsp::sample_traits::silence()); - } + m_hist.assign(channels * k_lines * m_hist_cap, tap::dsp::sample_traits::silence()); m_scratch.resize(k_pop_chunk * channels); reset(); } /// Push-transform: consumes all in_frames interleaved frames, writes /// outputs_for(in_frames) frames (out must hold them); returns that. - std::size_t process(const S* in, std::size_t in_frames, S* out) noexcept { + TAP_SR_RATIONAL_NOINLINE std::size_t process(const S* in, std::size_t in_frames, S* out) noexcept { + // The channel count as a compile-time constant for the common + // counts (bridge's process() does the same for its tap count): the + // channel loops unroll, and the block runs with two fewer live + // values in the engine's own frame. + switch (m_channels) { + case 1: + return process_block<1>(in, in_frames, out); + case 2: + return process_block<2>(in, in_frames, out); + default: + return process_block<0>(in, in_frames, out); + } + } + + /// process() for a channel count known at compile time (Ch), or at + /// run time (Ch == 0). + template + std::size_t process_block(const S* in, std::size_t in_frames, S* out) noexcept { std::size_t produced = 0; + cursor b = open(); + if constexpr (Ch != 0) { + b.channels = Ch; + } if constexpr (k_is_decimator) { for (std::size_t i = 0; i < in_frames; ++i) { - append_frame(in + i * m_channels); - if (m_dphase == 0) { - emit(out + produced * m_channels); + append_frame(b, in + i * b.channels); + if (b.dphase == 0) { + emit(b, out + produced * b.channels); ++produced; } - m_dphase = m_dphase + 1 == k_down ? 0 : m_dphase + 1; + b.dphase = b.dphase + 1 == k_down ? 0 : b.dphase + 1; } } else { const std::size_t total = outputs_for(in_frames); std::size_t fed = 0; for (; produced < total; ++produced) { - while (m_pending != 0) { // never outruns in_frames: total is exact - append_frame(in + fed * m_channels); + while (b.pending != 0) { // never outruns in_frames: total is exact + append_frame(b, in + fed * b.channels); ++fed; - --m_pending; + --b.pending; } - emit(out + produced * m_channels); + emit(b, out + produced * b.channels); } while (fed < in_frames) { // leftover input smaller than the next gap - append_frame(in + fed * m_channels); + append_frame(b, in + fed * b.channels); ++fed; - --m_pending; + --b.pending; } } + close(b); return produced; } @@ -297,29 +329,47 @@ namespace tap::sr::rational { /// the source ran dry and the call returns short, resumable). Bit- /// identical to process() on the same stream. PopFn must be noexcept. template - std::size_t pull(S* out, std::size_t out_frames, PopFn&& pop) noexcept { + TAP_SR_RATIONAL_NOINLINE std::size_t pull(S* out, std::size_t out_frames, PopFn&& pop) noexcept { static_assert(std::is_nothrow_invocable_r_v, "pull() requires a noexcept PopFn: std::size_t(S*, std::size_t) noexcept"); + switch (m_channels) { // the channel count as a constant, as process() + case 1: + return pull_block<1>(out, out_frames, pop); + case 2: + return pull_block<2>(out, out_frames, pop); + default: + return pull_block<0>(out, out_frames, pop); + } + } + + template + std::size_t pull_block(S* out, std::size_t out_frames, PopFn& pop) noexcept { + cursor b = open(); + if constexpr (Ch != 0) { + b.channels = Ch; + } for (std::size_t n = 0; n < out_frames; ++n) { // The inputs the next output needs: the schedule's pending // gap, or for a decimator the pushes up to and including the // one at phase 0 (whose frame emits). Consumed one by one, // so a dry source leaves the exact partial state behind. - std::size_t need = pending_inputs(); + std::size_t need = pending_inputs(b); while (need != 0) { const std::size_t want = need < k_pop_chunk ? need : k_pop_chunk; const std::size_t got = pop(m_scratch.data(), want); if (got == 0) { + close(b); return n; // dry } for (std::size_t i = 0; i < got && i < want; ++i) { - append_frame(m_scratch.data() + i * m_channels); - consume_one(); + append_frame(b, m_scratch.data() + i * b.channels); + consume_one(b); --need; } } - emit(out + n * m_channels); + emit(b, out + n * b.channels); } + close(b); return out_frames; } @@ -358,35 +408,52 @@ namespace tap::sr::rational { /// End of stream: feeds window_frames() zeros and writes the outputs /// that become ready — flush_output_frames() of them. The stage is /// left mid-stream in the zero-fed state; reset() before reuse. - std::size_t flush(S* out) noexcept { + TAP_SR_RATIONAL_NOINLINE std::size_t flush(S* out) noexcept { + switch (m_channels) { // the channel count as a constant, as process() + case 1: + return flush_block<1>(out); + case 2: + return flush_block<2>(out); + default: + return flush_block<0>(out); + } + } + + template + std::size_t flush_block(S* out) noexcept { const std::size_t zeros = window_frames(); std::size_t fed = 0; std::size_t made = 0; + cursor b = open(); + if constexpr (Ch != 0) { + b.channels = Ch; + } if constexpr (k_is_decimator) { for (; fed < zeros; ++fed) { - append_silence(); - if (m_dphase == 0) { - emit(out + made * m_channels); + append_silence(b); + if (b.dphase == 0) { + emit(b, out + made * b.channels); ++made; } - m_dphase = m_dphase + 1 == k_down ? 0 : m_dphase + 1; + b.dphase = b.dphase + 1 == k_down ? 0 : b.dphase + 1; } - return made; } else { for (;;) { - while (m_pending != 0 && fed < zeros) { - append_silence(); + while (b.pending != 0 && fed < zeros) { + append_silence(b); ++fed; - --m_pending; + --b.pending; } - if (m_pending != 0) { - return made; + if (b.pending != 0) { + break; } - emit(out + made * m_channels); + emit(b, out + made * b.channels); ++made; } } + close(b); + return made; } /// Frames flush() will write from the current position. @@ -394,10 +461,8 @@ namespace tap::sr::rational { /// Return to the zero-primed initial state. void reset() noexcept { - for (auto& line : m_hist) { - for (auto& v : line) { - v = tap::dsp::sample_traits::silence(); - } + for (auto& v : m_hist) { + v = tap::dsp::sample_traits::silence(); } for (auto& e : m_end) { e = m_row_len; @@ -549,6 +614,61 @@ namespace tap::sr::rational { m_count[r] = last - first; } + // ANCHOR: rational_cursor + /// The hot loops' view of the stage for one call: the stream state + /// (where each sub-line ends, the schedule position, the pending gap, + /// the decimator's phase) and the constants they index with, as + /// locals. open() loads it, the block runs against it, close() stores + /// the state back. The members stay authoritative between calls; + /// inside one, this is what lets the compiler keep the loop's state + /// in registers: with the entry out of line, `this` is reachable from + /// compact()'s memmove, so every field the loop carried would be + /// written back and reloaded around each frame. + struct cursor { + S* hist; + const coeff* rows; + std::size_t channels; + std::size_t row_len; + std::size_t cap; + /// The decimator's rows' nonzero spans (m_first / m_count), which + /// its unrolled emit reads as scalars; the interpolator indexes + /// them by a runtime phase and reads the members (measured: the + /// copy cost its Q15 rows 4 % on the M55, and saved the + /// decimator's float rows 3 %). + std::array first; + std::array count; + std::array end; + std::uint32_t pos; + std::uint32_t pending; + std::uint32_t dphase; + }; + + cursor open() noexcept { + cursor b{}; + b.hist = m_hist.data(); + b.rows = m_rows.data(); + b.channels = m_channels; + b.row_len = m_row_len; + b.cap = m_hist_cap; + if constexpr (k_is_decimator) { + b.first = m_first; + b.count = m_count; + } + b.end = m_end; + b.pos = m_pos; + b.pending = m_pending; + b.dphase = m_dphase; + return b; + } + + void close(const cursor& b) noexcept { + m_end = b.end; + m_pos = b.pos; + m_pending = b.pending; + m_dphase = b.dphase; + } + // ANCHOR_END: rational_cursor + /// Inputs to push before the next output: the schedule's pending gap, /// or for a decimator the pushes through the one at phase 0 (which /// emits): 1 + (M - phase) mod M. @@ -561,85 +681,96 @@ namespace tap::sr::rational { } } - void consume_one() noexcept { + static std::size_t pending_inputs(const cursor& b) noexcept { if constexpr (k_is_decimator) { - m_dphase = m_dphase + 1 == k_down ? 0 : m_dphase + 1; + return 1 + (k_down - b.dphase) % k_down; } else { - --m_pending; + return b.pending; } } - S* line(std::size_t c, std::size_t sub) noexcept { return m_hist[c * k_lines + sub].data(); } - const S* line(std::size_t c, std::size_t sub) const noexcept { return m_hist[c * k_lines + sub].data(); } + static void consume_one(cursor& b) noexcept { + if constexpr (k_is_decimator) { + b.dphase = b.dphase + 1 == k_down ? 0 : b.dphase + 1; + } + else { + --b.pending; + } + } + + /// Channel c's sub-line sub: the history is one planar buffer, + /// channels x k_lines lines of cap samples each. + static S* line(const cursor& b, std::size_t c, std::size_t sub) noexcept { + return b.hist + (c * k_lines + sub) * b.cap; + } /// Appends one interleaved frame to the history (the decimator's /// current sub-line); compacts a full line first. - void append_frame(const S* frame) noexcept { - const std::size_t sub = k_is_decimator ? m_dphase : 0; - if (m_end[sub] == m_hist_cap) { - compact(sub); + static void append_frame(cursor& b, const S* frame) noexcept { + const std::size_t sub = k_is_decimator ? b.dphase : 0; + if (b.end[sub] == b.cap) { + compact(b, sub); } - for (std::size_t c = 0; c < m_channels; ++c) { - line(c, sub)[m_end[sub]] = frame[c]; + for (std::size_t c = 0; c < b.channels; ++c) { + line(b, c, sub)[b.end[sub]] = frame[c]; } - ++m_end[sub]; + ++b.end[sub]; } - void append_silence() noexcept { - const std::size_t sub = k_is_decimator ? m_dphase : 0; - if (m_end[sub] == m_hist_cap) { - compact(sub); + static void append_silence(cursor& b) noexcept { + const std::size_t sub = k_is_decimator ? b.dphase : 0; + if (b.end[sub] == b.cap) { + compact(b, sub); } - for (std::size_t c = 0; c < m_channels; ++c) { - line(c, sub)[m_end[sub]] = tap::dsp::sample_traits::silence(); + for (std::size_t c = 0; c < b.channels; ++c) { + line(b, c, sub)[b.end[sub]] = tap::dsp::sample_traits::silence(); } - ++m_end[sub]; + ++b.end[sub]; } /// Slides the newest row_len - 1 samples of sub-line sub to the front /// of every channel's line (in place). - void compact(std::size_t sub) noexcept { - const std::size_t keep = m_row_len - 1; - for (std::size_t c = 0; c < m_channels; ++c) { - S* l = line(c, sub); - std::memmove(l, l + (m_end[sub] - keep), keep * sizeof(S)); + static void compact(cursor& b, std::size_t sub) noexcept { + const std::size_t keep = b.row_len - 1; + for (std::size_t c = 0; c < b.channels; ++c) { + S* l = line(b, c, sub); + std::memmove(l, l + (b.end[sub] - keep), keep * sizeof(S)); } - m_end[sub] = keep; + b.end[sub] = keep; } // ANCHOR: rational_emit /// One output frame at the current position; advances the schedule /// (the decimator's phase advances in process / pull / flush). - void emit(S* out) noexcept { + void emit(cursor& b, S* out) const noexcept { using tr = tap::dsp::sample_traits; if constexpr (k_is_decimator) { - for (std::size_t c = 0; c < m_channels; ++c) { + for (std::size_t c = 0; c < b.channels; ++c) { typename tr::accum acc{}; for (std::size_t j = 0; j < k_rows; ++j) { const std::size_t sub = (k_down - j) % k_down; - const S* window = line(c, sub) + m_end[sub] - m_row_len + m_first[j]; - acc = tap::dsp::accumulate_row(acc, m_rows.data() + j * m_row_len + m_first[j], window, - m_count[j]); + const S* window = line(b, c, sub) + b.end[sub] - b.row_len + b.first[j]; + acc = tap::dsp::accumulate_row(acc, b.rows + j * b.row_len + b.first[j], window, b.count[j]); } out[c] = finalize_output(acc); } } else { - const schedule_entry step = k_schedule[m_pos]; - const coeff* row = m_rows.data() + step.phase * m_row_len + m_first[step.phase]; - for (std::size_t c = 0; c < m_channels; ++c) { - const S* window = line(c, 0) + m_end[0] - m_row_len + m_first[step.phase]; + const schedule_entry step = k_schedule[b.pos]; + const coeff* row = b.rows + step.phase * b.row_len + m_first[step.phase]; + const std::size_t count = m_count[step.phase]; + const S* window = line(b, 0, 0) + b.end[0] - b.row_len + m_first[step.phase]; + for (std::size_t c = 0; c < b.channels; ++c, window += b.cap) { if constexpr (k_table_gain != 1) { - out[c] = finalize_output(tap::dsp::accumulate_row( - typename tap::dsp::sample_traits::accum{}, row, window, m_count[step.phase])); + out[c] = finalize_output(tap::dsp::accumulate_row(typename tr::accum{}, row, window, count)); } else { - out[c] = tap::dsp::dot_row(row, window, m_count[step.phase]); + out[c] = tap::dsp::dot_row(row, window, count); } } - m_pending = step.advance; - m_pos = m_pos + 1 == k_up ? 0 : m_pos + 1; + b.pending = step.advance; + b.pos = b.pos + 1 == k_up ? 0 : b.pos + 1; } } // ANCHOR_END: rational_emit @@ -651,7 +782,7 @@ namespace tap::sr::rational { std::vector m_rows; ///< k_rows x T, tap-reversed std::array m_first{}; ///< nonzero span start per row std::array m_count{}; ///< nonzero span length per row - std::vector> m_hist; ///< channels x k_lines planar delay lines + std::vector m_hist; ///< channels x k_lines planar lines of m_hist_cap each std::array m_end{}; ///< newest sample index + 1 per sub-line std::vector m_scratch; ///< interleaved staging for pull() std::uint32_t m_pos = 0; ///< superblock position in [0, L) diff --git a/rational/tests/CMakeLists.txt b/rational/tests/CMakeLists.txt index 809375e..8fe2185 100644 --- a/rational/tests/CMakeLists.txt +++ b/rational/tests/CMakeLists.txt @@ -54,7 +54,10 @@ if(TAP_SR_BUILD_CAPI) endif() # The tests' own headers (the committed reference vectors, the generated # matrix rows, the bridge adapter) resolve relative to this directory. -target_include_directories(tap_sr_rational_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +# The family's test support (tests/support/tap_sr_test/: the rejection +# assertions, with and without exceptions; the roadmap's A12). +target_include_directories(tap_sr_rational_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} + ${SampleRateTap_SOURCE_DIR}/tests/support) target_link_libraries(tap_sr_rational_tests PRIVATE tap::sr::rational tap_sr_bridge_headers diff --git a/rational/tests/test_chain.cpp b/rational/tests/test_chain.cpp index 1246c4f..c684384 100644 --- a/rational/tests/test_chain.cpp +++ b/rational/tests/test_chain.cpp @@ -25,6 +25,7 @@ #include "reference/reference_vectors.h" #include "tap/sr/rational/rational.h" +#include "tap_sr_test/expect_rejected.h" namespace { @@ -509,7 +510,7 @@ namespace { } TEST(Chain, ZeroChannelsThrows) { - EXPECT_THROW((up_2_up_2(0)), std::invalid_argument); + TAP_SR_EXPECT_REJECTED((up_2_up_2(0)), std::invalid_argument); } // The family's error model (A3): a chain's validate() is its stages' at @@ -522,20 +523,13 @@ namespace { {profile::super_economy(), profile::economy(), profile::balanced(), profile::transparent()}) { EXPECT_EQ(c3::validate(2, p), nullptr); EXPECT_EQ((basic_chain::validate(1, p)), nullptr); - EXPECT_NO_THROW((c3(2, p))); + TAP_SR_EXPECT_ACCEPTED((c3(2, p))); } EXPECT_STREQ(c3::validate(0), "tap::sr::rational::basic_stage: channels == 0"); profile bad = profile::economy(); bad.passband_frac = 0.5; EXPECT_EQ(c3::validate(1, bad), validate(bad.relaxed(c3::k_divisors[0]))); - std::string thrown; - try { - c3 c(1, bad); - } - catch (const std::invalid_argument& e) { - thrown = e.what(); - } - EXPECT_EQ(thrown, c3::validate(1, bad)); + TAP_SR_EXPECT_REJECTED_WITH(c3(1, bad), std::invalid_argument, c3::validate(1, bad)); } } // namespace diff --git a/rational/tests/test_design.cpp b/rational/tests/test_design.cpp index 1b7be38..6c05e2f 100644 --- a/rational/tests/test_design.cpp +++ b/rational/tests/test_design.cpp @@ -19,6 +19,7 @@ #include "tap/dsp/nyquist.h" #include "tap/sr/rational/rational.h" +#include "tap_sr_test/expect_rejected.h" namespace { @@ -256,32 +257,26 @@ namespace { TEST(Design, BadProfilesThrow) { profile p = profile::economy(); p.passband_frac = 0.5; // the lower rate's Nyquist: no transition band - EXPECT_THROW((design_stage(p)), std::invalid_argument); + TAP_SR_EXPECT_REJECTED((design_stage(p)), std::invalid_argument); p.passband_frac = 0.0; - EXPECT_THROW((design_stage(p)), std::invalid_argument); + TAP_SR_EXPECT_REJECTED((design_stage(p)), std::invalid_argument); p.passband_frac = -0.1; - EXPECT_THROW((design_stage(p)), std::invalid_argument); + TAP_SR_EXPECT_REJECTED((design_stage(p)), std::invalid_argument); profile q = profile::economy(); q.stopband_atten_db = 0.0; - EXPECT_THROW((design_stage(q)), std::invalid_argument); + TAP_SR_EXPECT_REJECTED((design_stage(q)), std::invalid_argument); q.stopband_atten_db = std::numeric_limits::quiet_NaN(); - EXPECT_THROW((design_stage(q)), std::invalid_argument); + TAP_SR_EXPECT_REJECTED((design_stage(q)), std::invalid_argument); // And the good ones do not. - EXPECT_NO_THROW((design_stage(profile::economy()))); - EXPECT_NO_THROW((design_stage(profile::transparent()))); + TAP_SR_EXPECT_ACCEPTED((design_stage(profile::economy()))); + TAP_SR_EXPECT_ACCEPTED((design_stage(profile::transparent()))); // validate() is the same verdict as a noexcept reason (A3): the text the // throw carries, or nullptr. static_assert(noexcept(validate(p))); EXPECT_NE(validate(p), nullptr); EXPECT_NE(validate(q), nullptr); EXPECT_EQ(validate(profile::economy()), nullptr); - try { - design_stage(q); - ADD_FAILURE() << "designed a NaN stopband"; - } - catch (const std::invalid_argument& e) { - EXPECT_STREQ(e.what(), validate(q)); - } + TAP_SR_EXPECT_REJECTED_WITH((design_stage(q)), std::invalid_argument, validate(q)); } // A spec no length within the search bound meets (no pin: searched to @@ -325,13 +320,8 @@ namespace { TEST(Design, UnmeetableSpecThrows) { const profile r = {.passband_frac = 0.4999, .stopband_atten_db = 160.0, .taps_per_branch = {0, 0, 0, 0, 0}}; - EXPECT_THROW((design_stage(r)), std::runtime_error); - try { - design_stage(r); - } - catch (const std::runtime_error& e) { - EXPECT_STREQ(e.what(), k_unmeetable_spec_reason); - } + // One search, not two: the text is the throw's (the designer's reason). + TAP_SR_EXPECT_REJECTED_WITH((design_stage(r)), std::runtime_error, k_unmeetable_spec_reason); } } // namespace diff --git a/rational/tests/test_footprint.cpp b/rational/tests/test_footprint.cpp index 957fad0..29521de 100644 --- a/rational/tests/test_footprint.cpp +++ b/rational/tests/test_footprint.cpp @@ -9,12 +9,12 @@ // A stage, with N = 2 m B - 1 taps (taps()), T the row length (row_length()), // R the stored rows (k_rows: L, or M decimating), P the history lines per // channel (M decimating, else 1), c the coefficient size, s the sample size, -// ch channels and V = sizeof(std::vector): +// ch channels: // // held = R T c the rows -// + (T + 64) P ch s the history, 64 frames of append slack +// + (T + 64) P ch s the history: one planar allocation of +// P x ch lines, 64 frames of append slack // + 16 ch s pull()'s pop scratch -// + P ch V the history's vectors // peak = max(design, 8 N + held) the double design h lives to the end of // the constructor (design: below) // @@ -70,8 +70,9 @@ namespace { const std::size_t c = sizeof(coeff); const std::size_t s = sizeof(sample); const std::size_t ch = st.channels(); - return request(Stage::k_rows * T * c) + P * ch * request((T + k_hist_slack) * s) + request(k_pop_chunk * ch * s) - + request(P * ch * sizeof(std::vector)); + // rows; the history, one planar allocation of P x ch lines; pull()'s scratch + return request(Stage::k_rows * T * c) + request(P * ch * (T + k_hist_slack) * s) + + request(k_pop_chunk * ch * s); } // The design's own peak, inside build_table (stage.h), for a pinned profile @@ -145,9 +146,8 @@ namespace { << fmt << ' ' << ratio_name << ' ' << np.name << ' ' << ch; if (ch == 2) { std::printf("[ measured ] rational stage %-3s %-9s %-13s 2 ch: N %3zu T %2zu, %6zu B held, %6zu " - "B peak in construction (sizeof(std::vector) %zu)\n", - fmt, ratio_name, np.name, st->taps(), st->row_length(), m.live_bytes, m.peak_bytes, - sizeof(std::vector)); + "B peak in construction\n", + fmt, ratio_name, np.name, st->taps(), st->row_length(), m.live_bytes, m.peak_bytes); } } } diff --git a/rational/tests/test_stage.cpp b/rational/tests/test_stage.cpp index 4273a76..d9f35eb 100644 --- a/rational/tests/test_stage.cpp +++ b/rational/tests/test_stage.cpp @@ -25,6 +25,7 @@ #include "reference/reference_vectors.h" #include "tap/dsp/decimate.h" #include "tap/sr/rational/rational.h" +#include "tap_sr_test/expect_rejected.h" namespace { @@ -806,7 +807,7 @@ namespace { } TEST(Stage, ZeroChannelsThrows) { - EXPECT_THROW((converter(0)), std::invalid_argument); + TAP_SR_EXPECT_REJECTED((converter(0)), std::invalid_argument); } // The family's error model (A3): validate() is the constructor's rejection @@ -822,21 +823,12 @@ namespace { EXPECT_EQ((basic_stage::validate(2, p)), nullptr); EXPECT_EQ((basic_stage::validate(4, p.relaxed({3, 2}))), nullptr); } - const auto rejection = [](auto make) -> std::string { - try { - make(); - return {}; - } - catch (const std::exception& e) { - return e.what(); - } - }; EXPECT_STREQ(stage::validate(0), "tap::sr::rational::basic_stage: channels == 0"); - EXPECT_EQ(rejection([] { stage s(0); }), stage::validate(0)); + TAP_SR_EXPECT_REJECTED_WITH(stage(0), std::invalid_argument, stage::validate(0)); profile bad = profile::economy(); bad.passband_frac = 0.5; // the lower rate's Nyquist: no transition band EXPECT_EQ(stage::validate(1, bad), validate(bad)) << "the designer's own text"; - EXPECT_EQ(rejection([&] { stage s(1, bad); }), stage::validate(1, bad)); + TAP_SR_EXPECT_REJECTED_WITH(stage(1, bad), std::invalid_argument, stage::validate(1, bad)); } // The same agreement where the designer finds no length: an unpinned @@ -850,14 +842,7 @@ namespace { const profile unmeetable = { .passband_frac = 0.4999, .stopband_atten_db = 160.0, .taps_per_branch = {0, 0, 0, 0, 0}}; EXPECT_STREQ(stage::validate(1, unmeetable), k_unmeetable_spec_reason); - std::string thrown; - try { - stage s(1, unmeetable); - } - catch (const std::runtime_error& e) { - thrown = e.what(); - } - EXPECT_EQ(thrown, stage::validate(1, unmeetable)); + TAP_SR_EXPECT_REJECTED_WITH(stage(1, unmeetable), std::runtime_error, stage::validate(1, unmeetable)); } } // namespace diff --git a/tests/support/tap_sr_test/expect_rejected.h b/tests/support/tap_sr_test/expect_rejected.h new file mode 100644 index 0000000..54d2fc8 --- /dev/null +++ b/tests/support/tap_sr_test/expect_rejected.h @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +// Copyright 2026 Timothy Place and the SampleRateTap contributors +// +// The family's rejection assertion, for a battery built with or without +// exceptions (the roadmap's A12; Q5: a current consumer builds every engine +// with -fno-exceptions -fno-rtti on the M33, M55 and Hexagon). +// +// An engine rejects a configuration through tap::dsp::raise +// (tap/dsp/detail/expects.h): a throw of E where the build has exceptions, +// std::terminate() where it does not, and the noexcept validate() beside each +// constructor names the reason either way (A3). So: +// +// TAP_SR_EXPECT_REJECTED(statement, E) +// with exceptions: EXPECT_THROW(statement, E) +// without, hosted: EXPECT_DEATH(statement, ""): the process terminates +// without, bare metal: nothing; gtest has no death tests there, and a +// terminate would end the one-shot run. The +// validate() checks beside each use are the +// rejection's evidence on those legs. +// +// TAP_SR_EXPECT_REJECTED_WITH(statement, E, text) +// with exceptions: the statement throws E, and its what() is `text` +// (the reason validate() gives: the A3 agreement) +// without: TAP_SR_EXPECT_REJECTED(statement, E); the text is +// validate()'s, which each use pins beside it +// +// TAP_SR_EXPECT_ACCEPTED(statement) +// with exceptions: EXPECT_NO_THROW(statement) +// without: the statement, run; a rejection terminates the +// battery, which fails it as surely +// +// TAP_SR_TEST_HAS_EXCEPTIONS says which build a file is in, for the tests +// that read an exception's text. +#ifndef TAP_SR_TEST_EXPECT_REJECTED_H +#define TAP_SR_TEST_EXPECT_REJECTED_H + +#include + +#include "tap/dsp/detail/expects.h" + +#define TAP_SR_TEST_HAS_EXCEPTIONS TAP_DSP_HAS_EXCEPTIONS + +#if TAP_DSP_HAS_EXCEPTIONS +#define TAP_SR_EXPECT_REJECTED(statement, exception) EXPECT_THROW(statement, exception) +#define TAP_SR_EXPECT_ACCEPTED(statement) EXPECT_NO_THROW(statement) +#define TAP_SR_EXPECT_REJECTED_WITH(statement, exception, text) \ + do { \ + bool tap_sr_rejected_ = false; \ + try { \ + statement; \ + } \ + catch (const exception& tap_sr_e_) { \ + tap_sr_rejected_ = true; \ + EXPECT_STREQ(tap_sr_e_.what(), (text)) << #statement; \ + } \ + EXPECT_TRUE(tap_sr_rejected_) << #statement " did not throw " #exception; \ + } while (false) +#else +#if defined(GTEST_HAS_DEATH_TEST) && GTEST_HAS_DEATH_TEST +#define TAP_SR_EXPECT_REJECTED(statement, exception) EXPECT_DEATH(statement, "") +#else +#define TAP_SR_EXPECT_REJECTED(statement, exception) static_cast(0) +#endif +#define TAP_SR_EXPECT_ACCEPTED(statement) \ + do { \ + statement; \ + } while (false) +#define TAP_SR_EXPECT_REJECTED_WITH(statement, exception, text) TAP_SR_EXPECT_REJECTED(statement, exception) +#endif + +#endif