diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7c1de0..2231991 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -818,10 +818,13 @@ jobs: # (the family's error model, A3: config::validate() before constructing, # the library's rejections terminate instead of throwing). The SDK is # already fetched into the first trees; the second configure reuses it. + # pico2_cyccnt's second build also takes the flash (XIP) layout, so + # both of PICO2_COPY_TO_RAM's settings link. - name: Build pico2_cyccnt without exceptions run: > cmake -S async/examples/pico2_cyccnt -B build-cyccnt-noexc -DPICO_BOARD=pico2 - -DPICO_CXX_ENABLE_EXCEPTIONS=0 -DFETCHCONTENT_SOURCE_DIR_PICO_SDK=build-cyccnt/_deps/pico_sdk-src + -DPICO_CXX_ENABLE_EXCEPTIONS=0 -DPICO2_COPY_TO_RAM=OFF + -DFETCHCONTENT_SOURCE_DIR_PICO_SDK=build-cyccnt/_deps/pico_sdk-src && cmake --build build-cyccnt-noexc -j 4 - name: Build pico2_dualcore without exceptions diff --git a/async/docs/HARDWARE_TESTING.md b/async/docs/HARDWARE_TESTING.md index 0376745..8b526fd 100644 --- a/async/docs/HARDWARE_TESTING.md +++ b/async/docs/HARDWARE_TESTING.md @@ -104,7 +104,10 @@ Two things this proves that emulation cannot: core (~1,140 steady-state instructions per frame against 3,125 cycles). Correlating CYCCNT against the QEMU steady-state instruction counts also calibrates the ratchet ("1 QEMU instruction ≈ N RP2350 - cycles") for all future M33 numbers. + cycles") for all future M33 numbers. The firmware times `bridge` (both + directions) and `rational` (↑2, ↓2, 2/3) at Q15 too, so the ratio is read + off every engine's datapath, and runs from SRAM by default + (`PICO2_COPY_TO_RAM`), as QEMU's memory has no wait states. - **Dual-core deployment** (harness shipped: [`examples/pico2_dualcore/`](../examples/pico2_dualcore/), self-validating PASS/FAIL phases). The README suggests dedicating the RP2350's second diff --git a/async/examples/pico2_cyccnt/CMakeLists.txt b/async/examples/pico2_cyccnt/CMakeLists.txt index 88e7418..ed75fdc 100644 --- a/async/examples/pico2_cyccnt/CMakeLists.txt +++ b/async/examples/pico2_cyccnt/CMakeLists.txt @@ -12,6 +12,12 @@ # (lwip/btstack/mbedtls/cyw43 are not used here). cmake_minimum_required(VERSION 3.24) +# Release unless asked otherwise: the M33 ratchet's workloads are Release +# (cmake/arm-cortex-m33-mps2.cmake), and the cycles here calibrate them. +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) +endif() + set(PICO_BOARD pico2 CACHE STRING "Pico SDK board") set(PICO_PLATFORM rp2350-arm-s CACHE STRING "Pico SDK platform (secure Arm)") @@ -49,16 +55,29 @@ pico_sdk_init() # ~3.8x the Q15 instruction count (1,856.7M vs 484.1M baselines) — slow # but a real number is still valuable. option(PICO2_MEASURE_FLOAT "Measure the float (soft FP64) datapath too" ON) +# The whole image runs from SRAM (the SDK's copy_to_ram binary type), as the +# ratchet's QEMU M33 runs from zero-wait-state memory: the cycles then +# measure the code, not the flash's XIP cache. OFF measures the default +# execute-in-place layout a deployment would ship. +option(PICO2_COPY_TO_RAM "Run the image from SRAM, matched to the ratchet (OFF: execute in place from flash)" ON) add_executable(pico2_cyccnt main.cpp) # Self-contained except for the header-only library itself. +# The family's three engines (bridge and rational for the synchronous cases; +# an engine's examples may reach a sibling, PLAN.md 4.2). target_include_directories(pico2_cyccnt PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../include + ${CMAKE_CURRENT_SOURCE_DIR}/../../../bridge/include + ${CMAKE_CURRENT_SOURCE_DIR}/../../../rational/include ${CMAKE_CURRENT_SOURCE_DIR}/../../../submodules/dsptap/include) target_link_libraries(pico2_cyccnt PRIVATE pico_stdlib cmsis_core) if(PICO2_MEASURE_FLOAT) target_compile_definitions(pico2_cyccnt PRIVATE PICO2_MEASURE_FLOAT=1) endif() +if(PICO2_COPY_TO_RAM) + pico_set_binary_type(pico2_cyccnt copy_to_ram) +endif() + pico_enable_stdio_usb(pico2_cyccnt 1) pico_enable_stdio_uart(pico2_cyccnt 0) pico_add_extra_outputs(pico2_cyccnt) # .uf2 etc. diff --git a/async/examples/pico2_cyccnt/README.md b/async/examples/pico2_cyccnt/README.md index e64850e..ebb7422 100644 --- a/async/examples/pico2_cyccnt/README.md +++ b/async/examples/pico2_cyccnt/README.md @@ -22,6 +22,19 @@ instructions of soft-double filter design), which this firmware does not time. It also tests the README's claim directly: Q15 stereo fits a 150 MHz core with room to spare, 12 channels at 48 kHz does not. +The family's two synchronous engines are measured beside `async` (the +family roadmap's A11): `bridge` in both directions (48 → 44.1 and +44.1 → 48 kHz) and `rational` up 2, down 2 and 2/3, all Q15 at the default +profile (`economy`), stereo, each `process()` of 32 input frames timed. Their +QEMU M33 steady states, from `bridge/bench/baselines.json` less the +construct-only legs (2 s of stereo), are 424.9 instructions per output frame +down ((173,755,176 − 136,279,322) / 88,200) and 311.5 up +((133,914,472 − 104,011,630) / 96,000): the firmware's cycles per output +frame divided by those is the same calibration ratio, read off a second +datapath. `rational`'s construct leg is its chain's (`down2_down2_q15_eco`), +so its single-stage totals still carry their own construction, and the +firmware's cycles are the measurement there. + ## Build This is a standalone project — it is *not* part of the root CMake build. @@ -39,6 +52,18 @@ cmake --build build -j Produces `build/pico2_cyccnt.uf2`. Options: - `-DPICO2_MEASURE_FLOAT=OFF` — skip the float (soft FP64) cases. +- `-DPICO2_COPY_TO_RAM=OFF` — execute in place from flash (the SDK's default + layout, what a deployment would ship) instead of the default here, the + SDK's `copy_to_ram` binary type: the whole image copied to SRAM at boot, + as the ratchet's QEMU M33 runs from zero-wait-state memory, so the cycles + measure the code and not the flash's XIP cache. Running both is the + measure of what the cache costs. + +The build type defaults to `Release` (`-O3`), as the ratchet's workloads are +built. The one flag that differs is the float ABI: the SDK compiles +`-mfloat-abi=softfp`, the ratchet's toolchain file `hard`; the Q15 cases +pass no floating-point arguments in their hot loops, so it moves only the +float rows. ## Flash @@ -69,7 +94,11 @@ Expected output: a header with the sys clock (150 MHz default), then one row per case — Q15 × {fast, balanced} × {1, 2, 12} channels, plus float 1ch — with mean/p99/max cycles per 32-frame block, derived cycles/frame, and the percentage of a 150 MHz core that one 48 kHz stream costs. A 12-channel (or -float) case that cannot allocate prints a `SKIP` row instead. The run ends +float) case that cannot allocate prints a `SKIP` row instead. A second table +follows for the synchronous engines — `bridge` 48>44.1 and 44.1>48, +`rational` up2, down2 and 2/3, Q15 economy stereo — with mean/p99/max cycles +per `process()` call, cycles per *output* frame, and one stream's share of +the core at the engine's output rate (stated on the row). The run ends with: ``` @@ -81,13 +110,19 @@ TAP_SR_PICO2_DONE - **cyc/frame ÷ 1,138** (Q15 balanced 2ch) and **÷ 3,326** (12ch) give the silicon cycles-per-QEMU-instruction ratio — the calibration constant for all M33 instruction baselines in the README table. +- **cyc/out ÷ 424.9** (`bridge` 48>44.1) and **÷ 311.5** (44.1>48) read the + same ratio off the synchronous datapath; the two ratios agreeing is the + check that one constant serves every engine. - **%core@48k** is the headline budget figure. The float rows exist to put a measured number on "soft-double accumulation is the wrong datapath here"; Q15/Q31 are the intended paths on FP64-less cores. - p99/max vs. mean shows the (small) jitter from FIFO compaction, servo - block-rate work and whole-sample slips; the workload runs from SRAM with - interrupts live, so USB housekeeping shows up in the tail. + block-rate work and whole-sample slips; the workload runs with interrupts + live, so USB housekeeping shows up in the tail. Deviation from the icount workload: the cycled input buffer is 4,800 frames (0.1 s) instead of 12,000 so the 12-channel case fits comfortably in the -RP2350's 520 KB SRAM; per-block work is unchanged. +RP2350's 520 KB SRAM; per-block work is unchanged. Under `copy_to_ram` the +image's code shares that SRAM too: 140,712 B of `.text` and 12,224 B of +`.bss` (arm-none-eabi-gcc 13.2.1, Release, exceptions on; 131,908 B of +`.text` without exceptions), which leaves the 12-channel case its room. diff --git a/async/examples/pico2_cyccnt/main.cpp b/async/examples/pico2_cyccnt/main.cpp index a3e4a79..298a95a 100644 --- a/async/examples/pico2_cyccnt/main.cpp +++ b/async/examples/pico2_cyccnt/main.cpp @@ -17,6 +17,21 @@ // // yields the "1 QEMU instruction ~= N RP2350 cycles" ratio that converts // every M33 instruction baseline into a real cycle budget. +// +// The family's synchronous engines are measured beside it (the roadmap's +// A11): bridge in both directions (147/160 and 160/147) and rational up 2, +// down 2 and 2/3, all Q15 at the default profile (economy), stereo, timing +// each process() of 32 input frames. Their QEMU M33 steady states, from the +// committed baselines less their construct-only legs (2 s of stereo): +// +// bridge down_q15_eco (48 -> 44.1) (173,755,176 - 136,279,322) / 88,200 +// = 424.9 insns per output frame +// bridge up_q15_eco (44.1 -> 48) (133,914,472 - 104,011,630) / 96,000 +// = 311.5 insns per output frame +// +// rational's construct leg is its chain's (down2_down2_q15_eco), so its +// single-stage totals still carry their own construction; the firmware's +// cycles are the measurement there. #include #include #include @@ -33,6 +48,8 @@ #include "pico/stdlib.h" #include "tap/dsp/detail/expects.h" #include "tap/sr/async/converter.h" +#include "tap/sr/bridge/converter.h" +#include "tap/sr/rational/converter.h" namespace { @@ -156,6 +173,74 @@ namespace { (st.underruns != 0 || st.overruns != 0 || sink != sink) ? " WARN: not steady-state" : ""); } + /// One synchronous engine (bridge or rational) streaming 32-frame input + /// blocks through process(), timed per call. The cost is stated per output + /// frame and as one stream's share of the core at the engine's output rate. + template + void runSyncCase(const char* engineName, const char* caseName, std::size_t channels, double outRateHz) { + if (const char* why = Engine::validate(channels)) { + std::printf("%-8s %-8s %3u SKIP (%s)\n", engineName, caseName, static_cast(channels), why); + return; + } + std::unique_ptr conv; + std::vector input; + std::vector out; + // Room for any ratio here: up 2 makes at most 2 * 32 + 1 frames a call. + const std::size_t outCap = (3 * kBlockFrames + 8) * channels; +#if TAP_DSP_HAS_EXCEPTIONS + try { + conv = std::make_unique(channels); + input = sineBlock(kInputFrames * channels, 997.0, 0.5); + out.resize(outCap); + } + catch (const std::exception& e) { + std::printf("%-8s %-8s %3u SKIP (%s)\n", engineName, caseName, static_cast(channels), e.what()); + return; + } +#else + conv = std::make_unique(channels); + input = sineBlock(kInputFrames * channels, 997.0, 0.5); + out.resize(outCap); +#endif + + double sink = 0.0; + std::size_t off = 0; + std::uint64_t outputs = 0; + const auto step = [&]() { + const std::size_t made = conv->process(input.data() + off, kBlockFrames, out.data()); + off += kBlockFrames * channels; + if (off + kBlockFrames * channels > input.size()) { + off = 0; + } + sink += static_cast(out[0]); + return made; + }; + + for (std::size_t i = 0; i < kWarmupIters; ++i) { + step(); + } + for (std::size_t i = 0; i < kMeasureIters; ++i) { + const std::uint32_t t0 = DWT->CYCCNT; + outputs += step(); + gCycles[i] = DWT->CYCCNT - t0; + } + + std::uint64_t sum = 0; + for (const std::uint32_t c : gCycles) { + sum += c; + } + std::sort(gCycles, gCycles + kMeasureIters); + const double mean = static_cast(sum) / static_cast(kMeasureIters); + const std::uint32_t p99 = gCycles[kMeasureIters * 99 / 100 - 1]; + const std::uint32_t mx = gCycles[kMeasureIters - 1]; + const double perFrame = static_cast(sum) / static_cast(outputs); + const double pctCore = perFrame * outRateHz / static_cast(clock_get_hz(clk_sys)) * 100.0; + std::printf("%-8s %-8s %3u %10.0f %10lu %10lu %10.1f %8.2f%% @ %5.1f kHz%s\n", engineName, caseName, + static_cast(channels), mean, static_cast(p99), + static_cast(mx), perFrame, pctCore, outRateHz / 1000.0, + sink != sink ? " WARN: non-finite output" : ""); + } + } // namespace int main() { @@ -171,7 +256,7 @@ int main() { static_cast(kWarmupIters), static_cast(kMeasureIters)); if (!enableCycleCounter()) { - std::printf("ERROR: DWT cycle counter not implemented\nSRT_PICO2_DONE\n"); + std::printf("ERROR: DWT cycle counter not implemented\nTAP_SR_PICO2_DONE\n"); while (true) sleep_ms(1000); } @@ -190,6 +275,19 @@ int main() { runCase("float", "balanced", tap::sr::async::filter_spec::balanced(), 1); #endif + // The synchronous engines, Q15 economy, stereo: cycles per output frame + // and one stream's share of the core at the engine's output rate. + std::printf("\n%-8s %-8s %3s %10s %10s %10s %10s %9s\n", "engine", "case", "ch", "mean/call", "p99/call", + "max/call", "cyc/out", "%core"); + using tap::sr::bridge::direction; + runSyncCase>("bridge", "48>44.1", 2, + 44100.0); + runSyncCase>("bridge", "44.1>48", 2, + 48000.0); + runSyncCase>("rational", "up2", 2, 48000.0); + runSyncCase>("rational", "down2", 2, 24000.0); + runSyncCase>("rational", "2/3", 2, 32000.0); + std::printf("TAP_SR_PICO2_DONE\n"); while (true) sleep_ms(1000); diff --git a/book/src/part5/hardware.md b/book/src/part5/hardware.md index 3c2ac97..96b305e 100644 --- a/book/src/part5/hardware.md +++ b/book/src/part5/hardware.md @@ -268,6 +268,21 @@ Per-block work is unchanged; the deviation is documented because an unexplained difference between two "identical" workloads is how calibration constants go quietly wrong. +Two more matches to the ratchet came with the roadmap's A11. The firmware +builds `Release` by default, as the workloads are built, and runs the whole +image from SRAM (the SDK's `copy_to_ram`, the `PICO2_COPY_TO_RAM` option, +on by default): QEMU's memory has no wait states and the RP2350's flash +does, behind a cache, so a ratio measured from flash would fold the cache's +misses into the constant. Turning the option off measures the layout a +deployment ships, and the difference between the two runs is what the cache +costs. And the calibration is no longer one engine's: a second table times +`bridge` in both directions and `rational` at ↑2, ↓2 and 2/3, all Q15 +`economy` stereo, per `process()` of 32 input frames and per output frame. +`bridge`'s M33 steady states, its baselines less its construct-only legs, +are 424.9 instructions per output frame going down and 311.5 going up, so +its rows give the same ratio read off a different datapath — the check +that one constant serves the family. + ## `pico2_dualcore`: one clock domain per core The README's platform guidance ends with a suggestion: on Pico-class diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 762f0e4..37ff27b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -130,7 +130,7 @@ gated on shipping. `docs/AUDIT_2026-10_PLAN.md` still says "execution in progress"; the comparison records' competitor pins are frozen by policy and each `COMPARISON.md` should say so with the date. -- **A11. Harness prerequisites for Block B** (written now, run then). *In three PRs. A11a landed 2026-10-15: the ALSA bridge's counters, xruns, `hw:`-only refusal, `--rt` and hardware timestamps, `examples/alsa_bridge_report.py` (the regression slope, wobble and breathing; self-tested), and `libasound2-dev` on the Linux legs and in the tidy gate. A11b is the `pico2_cyccnt` cases and A11c the UAC2 firmware.* the ALSA bridge +- **A11. Harness prerequisites for Block B** (written now, run then). *In three PRs. A11a landed 2026-10-15: the ALSA bridge's counters, xruns, `hw:`-only refusal, `--rt` and hardware timestamps, `examples/alsa_bridge_report.py` (the regression slope, wobble and breathing; self-tested), and `libasound2-dev` on the Linux legs and in the tidy gate. A11b landed 2026-10-15: `pico2_cyccnt` times `bridge` (both directions) and `rational` (↑2, ↓2, 2/3) at Q15 `economy`, per output frame, beside `async`, and runs from SRAM by default (`PICO2_COPY_TO_RAM`), Release, as the ratchet's workloads; CI links both layouts. A11c is the UAC2 firmware.* the ALSA bridge gains per-device cumulative frame counters sampled with `clock_gettime` in the CSV, separate ALSA xrun counters, a refusal of non-`hw:` devices (the `default` device on a Pi routes through PipeWire and its own resampler), an `--rt` flag (`SCHED_FIFO`,