Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion async/docs/HARDWARE_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions async/examples/pico2_cyccnt/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)")

Expand Down Expand Up @@ -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.
43 changes: 39 additions & 4 deletions async/examples/pico2_cyccnt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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:

```
Expand All @@ -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.
100 changes: 99 additions & 1 deletion async/examples/pico2_cyccnt/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <algorithm>
#include <cmath>
#include <cstdint>
Expand All @@ -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 {

Expand Down Expand Up @@ -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 <typename Engine>
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<unsigned>(channels), why);
return;
}
std::unique_ptr<Engine> conv;
std::vector<std::int16_t> input;
std::vector<std::int16_t> 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<Engine>(channels);
input = sineBlock<std::int16_t>(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<unsigned>(channels), e.what());
return;
}
#else
conv = std::make_unique<Engine>(channels);
input = sineBlock<std::int16_t>(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<double>(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<double>(sum) / static_cast<double>(kMeasureIters);
const std::uint32_t p99 = gCycles[kMeasureIters * 99 / 100 - 1];
const std::uint32_t mx = gCycles[kMeasureIters - 1];
const double perFrame = static_cast<double>(sum) / static_cast<double>(outputs);
const double pctCore = perFrame * outRateHz / static_cast<double>(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<unsigned>(channels), mean, static_cast<unsigned long>(p99),
static_cast<unsigned long>(mx), perFrame, pctCore, outRateHz / 1000.0,
sink != sink ? " WARN: non-finite output" : "");
}

} // namespace

int main() {
Expand All @@ -171,7 +256,7 @@ int main() {
static_cast<unsigned>(kWarmupIters), static_cast<unsigned>(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);
}
Expand All @@ -190,6 +275,19 @@ int main() {
runCase<float>("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<tap::sr::bridge::basic_converter<std::int16_t, direction::down_to_44k1, 0>>("bridge", "48>44.1", 2,
44100.0);
runSyncCase<tap::sr::bridge::basic_converter<std::int16_t, direction::up_to_48k, 0>>("bridge", "44.1>48", 2,
48000.0);
runSyncCase<tap::sr::rational::converter_q15<tap::sr::rational::up_2>>("rational", "up2", 2, 48000.0);
runSyncCase<tap::sr::rational::converter_q15<tap::sr::rational::down_2>>("rational", "down2", 2, 24000.0);
runSyncCase<tap::sr::rational::converter_q15<tap::sr::rational::ratio_2_3>>("rational", "2/3", 2, 32000.0);

std::printf("TAP_SR_PICO2_DONE\n");
while (true)
sleep_ms(1000);
Expand Down
15 changes: 15 additions & 0 deletions book/src/part5/hardware.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Loading