Skip to content

A5 (ceilings): each engine's code size and stack per call, gated - #87

Merged
tap merged 1 commit into
mainfrom
claude/sample-rate-solutions-comparison-mqc190
Oct 14, 2026
Merged

tap merged 1 commit into
mainfrom
claude/sample-rate-solutions-comparison-mqc190

Conversation

@tap

@tap tap commented Oct 13, 2026

Copy link
Copy Markdown
Owner

What this changes

This is the second half of roadmap item A5, and it closes row P5 for all three engines. A new script, scripts/ceilings.py, measures one small probe program per engine and sample format, and checks every figure against a ceiling recorded in <engine>/bench/ceilings.json. The ceilings are one-sided: a figure fails only if it rises above its ceiling. The check runs as new steps in the existing instruction-count ratchet job.

The probes (<engine>/bench/icount/probe_main.cpp, built by tools/probe/probes.cmake) each stream 32-frame stereo blocks at the default profile:

  • async: one converter, calling push() and pull();
  • bridge: the 44.1 → 48 kHz and 48 → 44.1 kHz converters, each calling process();
  • rational: an up_2 stage and a down_2 stage, each calling process().

The probes are built Release in the ratchet's own build tree, so the ceilings describe the same code the instruction counts do. Their names stay outside the ratchet's *_icount_* pattern.

Code size, on M33, M55 and Hexagon:

  • Engine .text and .rodata: the input sections of the probe's own source file, read from its link map, which means the engine's headers as compiled into the probe. The map is needed because the Arm linker scripts put .rodata inside .text, so size can't separate them.
  • Image: the whole linked probe's .text plus .rodata, including the C/C++ runtime and startup code.

Stack, on M33, per call, as two figures:

  • Static worst path: the deepest chain of stack frames reachable from the call. Compiled code uses the frame sizes GCC reports with -fstack-usage -fcallgraph-info=su. Precompiled runtime routines (newlib, libgcc's soft-double helpers) are absent from GCC's graph, so their frames are read from the linked probe's disassembly. The result bounds every path, not just the one the probe takes.
  • Measured high-water: the probe fills the stack below the call with a pattern, runs a second of blocks under QEMU, and records the deepest byte overwritten (tools/probe/stack_meter.h).

The script fails if the measured figure ever exceeds the static bound, since that would mean the analysis missed a frame.

Ceiling rule: recorded figure + 3 %, rounded up to 64 bytes for sizes (DspTap's bench.yml rule) or 8 bytes for stack. Each engine README's Footprint section gains a "Code size and stack" subsection. Its tables are generated from the ceilings file (scripts/ceilings.py docs), and the existing docs-freshness job checks they're current.

Why

Row P5 asks that each engine's memory footprint and per-call stack use be stated and gated. #86 covered the heap. This PR covers the code a deployment links and the stack each real-time call needs. On a Pico 2 class part, those are budgeted the same way the heap is.

The two stack figures are both needed. My first run used GCC's frames alone, and async's pull came out at 160 B static while the measured high-water was 256 B. The gap was newlib's exp and libgcc's soft-double helpers, which are precompiled and have no frames in GCC's call graph. Once their frames come from the disassembly, the static figure (276 B) bounds the measured one on every call.

Recorded figures, M33. Sizes are the engine's own .text. Stack is the largest call, static / measured:

engine engine .text (f32 / Q15 / Q31) largest stack
async 15,172 / 15,956 / 16,068 B pull 276 / 256 B (f32)
bridge 31,080 / 32,688 / 31,876 B down 328 / 216 B (f32); 288 / 268 B (Q15)
rational 8,964 / 11,044 / 10,520 B up 128 / 128 B (f32)

The full tables, M55 and Hexagon included, are in each README.

The roadmap says "per-profile" ceilings. Here they are per sample format: the quality profiles are runtime values over a single compiled instantiation, so they all share the same code. The roadmap entry says so.

Verification

  • Recorded locally with the toolchains CI uses: arm-none-eabi-gcc 13.2.1 (15:13.2.rel1-2), clang 19.1.5 for Hexagon, and qemu-system-arm on mps2-an505. All three are the ones that have reproduced the ratchet's counts exactly before. Recording means scripts/ceilings.py measure --update in a tree configured exactly as the ratchet job configures it.
  • Gate re-run against the committed files: all 21 M33 figures pass for each engine, and all 9 M55 and Hexagon figures for each engine.
  • Negative test: with two ceilings lowered in a scratch copy of the file, the gate fails on exactly those two figures.
  • Builds and lint:
    • Host builds of all 18 probes are warning-free with warnings as errors, under both clang and GCC.
    • clang-tidy 18 is clean on both probe variants, against a clang compile database and against the GCC one CI's tidy job generates.
    • That second check caught a real bug, fixed in this PR: clang rejects -fcallgraph-info, so the flag is now added only on the bare-metal GCC legs.
  • Also clean: clang-format, scripts/check_retired_words.py, and scripts/ceilings.py docs --check.
  • CI is the first run on GitHub's runners. On Hexagon the job finds llvm-size in the toolchain's bin directory, which it already puts on PATH.

Notes for the reviewer

  • The image figure is mostly runtime: newlib or musl, the C++ library, libgcc and libm. It's gated as DspTap gates its probes. The engine .text and .rodata figures are the ones that move when an engine changes.
  • Hexagon's engine .rodata is 0 B for async: clang keeps those constants out of .rodata there. A ceiling of 0 B means any constant data that appears later fails the gate and has to be re-recorded with a reason.
  • The measured stack figure covers the path the stream actually took, through priming, lock and steady state; the static figure covers every path. bridge's f32 and Q31 static figures sit well above their measured ones because the bound includes branches the stream never takes.
  • The ratchet's counted binaries are unchanged. The probes are separate executables.

🤖 Generated with Claude Code

https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf


Generated by Claude Code

The second half of the roadmap's A5 (row P5). scripts/ceilings.py
measures one probe per engine and format and gates each figure one-sided
against <engine>/bench/ceilings.json, in the ratchet job on every target:

- The probes (<engine>/bench/icount/probe_main.cpp, tools/probe/
  probes.cmake): async's stereo converter at the default profile through
  push() and pull(); bridge's duplex pair and rational's by-2 pair through
  process(). They stream 32-frame blocks and are built Release in the
  ratchet's tree, so the ceilings describe the code the counts do.
- Sizes on M33, M55 and Hexagon: the engine's own .text and .rodata (the
  probe's translation unit, from its link map; the Arm linker scripts fold
  .rodata into .text, so `size` cannot split them) and the whole image's
  .text + .rodata.
- Stack on M33, per call, as two figures:
  - the static worst path: GCC's frames under -fstack-usage
    -fcallgraph-info=su for the compiled code, plus the frames of the
    precompiled runtime routines it reaches, read from the linked
    probe's disassembly;
  - the high-water painted and read under QEMU over a second of blocks
    (tools/probe/stack_meter.h).
  The script fails if the painted figure exceeds the bound. The first
  measurement without the runtime frames showed why both are needed:
  async pull painted 256 B against a 160 B GCC-only path, the difference
  being newlib's exp and libgcc's soft-double helpers.

Each ceiling is the recorded figure + 3 %, rounded up to 64 B (sizes, the
DspTap bench.yml rule) or 8 B (stack). The READMEs' tables are generated
from the files (scripts/ceilings.py docs, checked by the icount-docs job).

Recorded locally with the CI toolchains (arm-none-eabi-gcc 13.2.1
15:13.2.rel1-2, clang 19.1.5 for Hexagon, qemu-system-arm on mps2-an505).
Largest M33 stack per call: async pull 276 B static / 256 B painted, bridge
328 / 268, rational 128 / 128. Engine .text on M33: async 15.2-16.1 KB,
bridge 31.1-32.7 KB, rational 9.0-11.0 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf
@tap
tap merged commit c8868c9 into main Oct 14, 2026
31 checks passed
@tap
tap deleted the claude/sample-rate-solutions-comparison-mqc190 branch October 14, 2026 10:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants