From f15adeb442247475d21a084956468f4ab0e559c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:08:38 +0000 Subject: [PATCH 1/2] A11b: pico2_cyccnt times bridge and rational, from SRAM The firmware gains a second table: bridge in both directions (48 -> 44.1, 44.1 -> 48) and rational up 2, down 2 and 2/3, all Q15 economy stereo, each process() of 32 input frames timed with DWT.CYCCNT and stated per output frame and as one stream's share of the core at its output rate. bridge's M33 steady states (baselines less the construct-only legs: 424.9 and 311.5 instructions per output frame) give the calibration ratio off a second datapath. PICO2_COPY_TO_RAM (default ON) runs the image from SRAM, as the ratchet's QEMU M33 runs from zero-wait-state memory; OFF measures the flash (XIP) layout a deployment ships. The build type defaults to Release, as the workloads are built. CI's no-exceptions build takes the XIP layout so both settings link. The error path's done marker now matches the success path's (TAP_SR_PICO2_DONE). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf --- .github/workflows/ci.yml | 5 +- async/docs/HARDWARE_TESTING.md | 5 +- async/examples/pico2_cyccnt/CMakeLists.txt | 19 ++++ async/examples/pico2_cyccnt/README.md | 43 ++++++++- async/examples/pico2_cyccnt/main.cpp | 100 ++++++++++++++++++++- book/src/part5/hardware.md | 15 ++++ 6 files changed, 180 insertions(+), 7 deletions(-) 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 From 03eb30c5914e2fb651cd910297bb6a51a64504e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:23:41 +0000 Subject: [PATCH 2/2] A11b: the roadmap's A11 note Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf --- docs/ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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`,