Skip to content

Faster match finding and decompression on Cortex-M7-class targets#349

Draft
BrianPugh wants to merge 40 commits into
mainfrom
stm32h7-optimizations
Draft

Faster match finding and decompression on Cortex-M7-class targets#349
BrianPugh wants to merge 40 commits into
mainfrom
stm32h7-optimizations

Conversation

@BrianPugh

Copy link
Copy Markdown
Owner

Stacked on #348 (uses its on-device harness for all measurements; retarget to main after that merges). Every change here benefits default library builds or documented compile-time knobs — nothing is benchmark-harness-specific.

Measured on STM32H7B0 @ 280 MHz (100 KB enwik8, window=10, byte-identical output in all configurations): the default build goes from 192.9 → 261.6 KB/s compression (1.36x) and 5.91 → 6.73 MB/s decompression (1.14x); the long-match repetitive workload's decompression improves 56%.

Changes

First-byte-prefilter match finder, default on ARMv7E-M (compressor_find_match_prefilter.c). On-device profiling showed find_best_match is 92% of compression time. The prefilter scans for the first input byte only — one 64-bit load + one zero-byte detect per 8 window positions — and verifies the 2-byte seed with a 16-bit load per hit. On in-order 32-bit cores this halves the SWAR work and live constants vs the desktop implementation (which spills heavily on 13 registers); it beats both the embedded baseline and the desktop implementation on both enwik8 and repetitive workloads. On out-of-order 64-bit hosts it is ~2x slower than the desktop implementation (which stays the 64-bit default), so it is gated to __ARM_ARCH_7EM__ + GCC + little-endian + unaligned, with TAMP_USE_EMBEDDED_MATCH=1 as the escape hatch and TAMP_USE_PREFILTER_MATCH=1 to opt in elsewhere.

Best-length gate in the prefilter and desktop match extenders: a candidate can only beat the current best match if it also agrees at offset *match_size, so a single byte compare rejects most candidates before extension. Provably output-identical; +5% on Cortex-M7, neutral within noise on x86_64/arm64.

Decompressor hot paths (tamp_window_copy no-wrap fast path; refill_bit_buffer on locals with a single writeback instead of repeated dereferences through aliasing-opaque pointers): +14% decompression on M7, +56% on the repetitive workload.

Reviewer judgment calls

  • The prefilter gate covers Cortex-M4 (same __ARM_ARCH_7EM__), which I could not measure — the structural argument (fewer ops/byte, unaligned loads supported) says it wins there, but this branch's own history shows such reasoning mispredicting real silicon. Escape hatch exists; happy to narrow the gate if you'd rather wait for M4 data.
  • The decompressor fast paths are gated off for __ARM_ARCH_6M__: they cost +150..500 bytes there (loop unrolling / register spills on the 8-register file) and their M0+ speed is unvalidated. armv6m binary sizes are byte-identical to before this PR (make binary-size checked). An RP2040 on-device A/B is a good follow-up.
  • ESP32-C3 uses the generic copy macros, so its BENCHMARKS.md rows are likely stale-pessimistic after this PR; worth re-measuring when a board is at hand.

Measured but rejected

32-bit SWAR narrowing (0.93x on M7 — 6 positions per 2 loads amortizes worse than 14), __usub8/__sel SIMD32 (0.54x — GCC's GE-flag codegen serializes the loop), 16-byte loop unroll and 32-bit prefilter loop variants (both slower), ITCM code / DTCM window placement (<1% with I+D caches enabled — now documented as guidance in the device README instead).

Validation

  • Full on-device suite passes in default, TAMP_USE_DESKTOP_MATCH, and TAMP_USE_EMBEDDED_MATCH configurations; compressed output remains byte-identical to the v1 reference in all of them.
  • make c-test, make c-test-embedded, and uv run pytest (143 tests + 267 subtests, includes dataset regression through the Cython build) all pass.
  • Host A/B benchmarks (arm64 + x86_64 via Rosetta): desktop implementation with the gate is neutral; host decompression neutral within noise.

🤖 Generated with Claude Code

@BrianPugh
BrianPugh force-pushed the stm32h7-optimizations branch from b8ff1a5 to b8a10ba Compare July 15, 2026 16:40
Base automatically changed from stm32h7b0-device-harness to main July 15, 2026 16:45
BrianPugh and others added 4 commits July 15, 2026 12:56
TAMP_USE_DESKTOP_MATCH=1 forces the existing 64-bit desktop find_best_match
on non-64-bit targets (needs little-endian + cheap unaligned loads), and
TAMP_USE_SWAR32_MATCH=1 selects a new 32-bit narrowing of the same algorithm.
The device Makefile gains EXTRA_CFLAGS passthrough with a flags stamp so
variant rebuilds actually happen.

Measured on STM32H7B0 @ 280 MHz (enwik8-100KB, w=10, all byte-identical
output): embedded baseline 192.9 KB/s, desktop 64-bit 214.0 KB/s (1.11x),
swar32 178.8 KB/s (0.93x). A __usub8/__sel SIMD32 variant measured 0.54x
(GCC's GE-flag codegen serializes the loop) and was dropped. The 64-bit
implementation is documented as the M7 recommendation; swar32 is kept
flag-gated as the candidate for single-issue cores (RP2350/M33 next).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The prefilter scans for the first input byte only (one 64-bit load + one
zero-byte detect per 8 window positions), verifying the 2-byte seed with a
16-bit load per hit. On in-order 32-bit cores this halves the SWAR work and
the live constants vs the desktop implementation; measured on STM32H7B0 @
280 MHz it is 1.36x the embedded baseline and beats it on both enwik8 and
the repetitive workload, with byte-identical output. On out-of-order 64-bit
hosts it is slower than the desktop implementation, so it is gated to
ARMv7E-M (GCC, little-endian, unaligned) with TAMP_USE_EMBEDDED_MATCH as the
escape hatch, and available elsewhere via TAMP_USE_PREFILTER_MATCH=1.

Both prefilter and desktop implementations gain a best-length gate: a
candidate can only beat the current best match if it agrees at offset
*match_size, so one byte compare rejects most candidates before extension
(+5% on Cortex-M7 for both; neutral within noise on x86_64/arm64).

Cortex-M4 shares __ARM_ARCH_7EM__ and is expected to benefit for the same
structural reasons but has not been measured on hardware.

The device Makefile now lists the #include'd match variants as prerequisites
so editing them triggers a rebuild.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tamp_window_copy gains a fast path for the common case where the destination
run does not wrap: plain indexed copies instead of a per-byte mask and
pointer dereference. refill_bit_buffer works on locals with a single
writeback instead of operating through pointers (the unsigned char loads may
alias anything, forcing a reload/store per byte).

Measured on STM32H7B0 @ 280 MHz: enwik8 decompression 16,929 -> 14,854 us
(+14%, 6.73 MB/s) and the long-match repetitive workload 8,460 -> 5,414 us
(+56%). Neutral within noise on x86_64/arm64 hosts.

Cortex-M0/M0+ (armv6m) keeps the original code for both functions: the
fast-path variants cost +150..500 bytes there (loop unrolling and register
spills on the 8-register file) and their speed on that core is unvalidated;
armv6m binary sizes are byte-identical to before this change. Worth an
on-device RP2040 A/B later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
STM32H7B0 default build (prefilter + hot-path improvements): compression
192.9 -> 261.6 KB/s, decompression 5.91 -> 6.73 MB/s. Adds measured rows for
the TAMP_USE_DESKTOP_MATCH / TAMP_USE_EMBEDDED_MATCH knobs, and README
placement guidance: with I+D caches enabled, ITCM code / DTCM window
placement measured <1% on this workload - cache enablement is what matters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@BrianPugh
BrianPugh force-pushed the stm32h7-optimizations branch from b8a10ba to 869c4cc Compare July 15, 2026 16:57
BrianPugh and others added 2 commits July 15, 2026 13:06
common.c, compressor.c, and decompressor.c now contain only dispatch
blocks; each architecture-selected implementation lives in its own
included-not-compiled file, following the existing
compressor_find_match_desktop.c convention:

- compressor_find_match_embedded.c (was inline in compressor.c)
- common_window_copy_{fast,compact}.c (was #if __ARM_ARCH_6M__ in common.c)
- decompressor_refill_{fast,compact}.c (was #if __ARM_ARCH_6M__ in
  refill_bit_buffer)

The espidf component's staged file list gains the new files; the embedded
match finder is required there for TAMP_ESP32=0 builds (the noopt benchmark
variant). No behavioral change: armv6m binary sizes and on-device STM32H7B0
numbers are identical, and all C/Python tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@BrianPugh
BrianPugh force-pushed the stm32h7-optimizations branch from 53e35aa to 93a4d80 Compare July 15, 2026 17:07
BrianPugh and others added 16 commits July 15, 2026 13:22
common.c, compressor.c, and decompressor.c must compile standalone with only
the headers so users can vendor the three files onto embedded targets. The
previous commit's #include'd variant files broke that for every build, so
this restores the ESP32-style contract: implementations reachable on
embedded targets are defined inline (embedded + prefilter match finders,
window copy, bit-buffer refill, each with a small #if where Cortex-M0/M0+
needs the compact form), while only host/experiment-reachable variants stay
in separate files (compressor_find_match_desktop.c, _swar32.c) or come from
a platform component (TAMP_ESP32 extern find_best_match, private/tamp_copy.h).

tamp_window_copy is now a single body for all targets, with just the no-wrap
fast-path branch compiled out on armv6m. The shared local-position caching
costs +20 bytes there vs the previous duplicated original (it removes a
per-byte pointer dereference and store from the copy loops, which is
expected but not yet measured to be faster on M0+; RP2040 A/B pending).
Verified: the three files compile standalone for cortex-m0plus, cortex-m4,
cortex-m7, and host; STM32H7B0 on-device numbers are unchanged (261.6 KB/s
/ 6.73 MB/s); all C and Python tests pass; espidf staged file list restored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-device A/B on ESP32-S3 (Xtensa LX7, generic TAMP_ESP32=0 build) showed
both generic-path decompressor changes regress there: the locals-based
refill_bit_buffer costs ~3% and the tamp_window_copy no-wrap fast path
~2.7%. Flip both gates from "everything except Cortex-M0/M0+" to
"only ARMv7E-M", the sole target where they are measured wins (+14%
decompression on Cortex-M7, unchanged at 6.73 MB/s after this commit).

The shared local-position caching in tamp_window_copy stays for all targets:
it measured +1.6% decompression on the S3 generic build (now 1.91 MB/s,
above the previously published 1.88 MB/s row, updated) and is expected
slightly positive on Cortex-M0+ (+20 bytes, RP2040 A/B still pending).
TAMP_ESP32 builds re-measured byte-for-byte at their published numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All architecture detection moves into a single "Platform performance tuning"
section in common.h that computes per-arch defaults for semantic flags
(TAMP_USE_{EMBEDDED,PREFILTER,DESKTOP,SWAR32}_MATCH, TAMP_FAST_WINDOW_COPY,
TAMP_FAST_BIT_REFILL), each overridable with -D. The .c files no longer
mention any architecture, and variant selection happens at whole-function
granularity (two complete definitions under one #if) instead of #if/#else
blocks inside function bodies. compressor.c's match-finder dispatch reduces
to one semantic flag per branch.

Provably a functional no-op: .text sections of common/compressor/decompressor
are byte-identical before and after on cortex-m0plus, cortex-m7, and host.
Also verified: override combinations compile (forcing either fast flag on
M0+, embedded match on M7, prefilter/swar32 on host), both C test suites
pass, on-device STM32H7B0 suite passes, armv6m sizes unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The TAMP_USE_DESKTOP_MATCH row was an experiment datapoint, not a
configuration anyone would choose (slower than the default prefilter). Keep
the default row and the TAMP_USE_EMBEDDED_MATCH fallback, which runs the
same code path as the other devices' generic rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ESP32 rows labeled the optimized build "TAMP_ESP32" and the generic
build "-", which made the optimizations look opt-in; CONFIG_TAMP_ESP32
actually defaults to y in the espidf component, matching the library-wide
convention (desktop SWAR on 64-bit hosts, prefilter/fast paths on ARMv7E-M):
the default build is the best measured configuration for the platform, with
explicit escape hatches. "-" now always marks the default build and other
rows state the options that deviate from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The core C sources no longer select architecture-specific code on their own:
every tuning flag defaults to the portable implementation, removing the
inconsistency where ESP32 optimizations required explicit enablement but
x86_64/aarch64 and ARMv7E-M self-selected via preprocessor sniffing. Each
build system now opts into its platform's measured configuration:

- setup.py (pip/Cython) sets TAMP_USE_DESKTOP_MATCH=1 on 64-bit hosts
  (unchanged behavior for pip users)
- the espidf component's Kconfig already defaults TAMP_ESP32=y (unchanged)
- devices/stm32h7b0 builds with the new TAMP_ARMV7EM=1 profile flag, which
  enables the prefilter match finder and both fast paths
- ctests keep desktop-path coverage via an explicit define; c-test-embedded
  still forces the portable scan (TAMP_USE_EMBEDDED_MATCH=1 now overrides
  any other selection instead of losing to an explicit desktop define)

Vendors of the raw three files get portable code on every architecture and
enable TAMP_ARMV7EM=1 per the docs. The M7 fallback benchmark row is
re-measured as fully portable (192.9 KB/s / 6.57 MB/s - the unconditional
local-position improvement lifts even the portable path from the original
5.91 MB/s); the default row is unchanged at 261.6 KB/s / 6.73 MB/s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TAMP_USE_EMBEDDED_MATCH previously force-overrode the other selections;
a conflicting explicit configuration now fails with #error instead of
silently picking one. The ctest desktop define moves out of the shared
CTEST_DEFINES onto only the non-embedded tamp objects so c-test-embedded
selects exactly one implementation. Combinations that rely on defaults
yielding (e.g. TAMP_ARMV7EM=1 plus an explicit match override) still work
because the prefilter default suppresses itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Converting it to a static inline function was attempted in three shapes
(by-pointer outputs, pure value return, always_inline); all measured
3.4-4.3% slower compression on Cortex-M7 (395-399ms vs 382ms on the enwik8
workload) despite full inlining - the function boundary changes GCC's
optimization order and register allocation in this pressure-critical loop.
The macro carries a comment recording that finding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Supersedes the previous commit's keep-the-macro decision: an unexplained
register-allocation advantage is not durable justification for a giant
scope-capturing macro. The mechanism is now understood - both forms compile
to 36-instruction scan loops, but across the inline boundary GCC keeps a
broadcast-pattern half in a register (cheaper no-hit path) at the cost of a
save/restore pair around every hit block, which loses on text-like inputs
where first-byte hits are frequent. That is a compiler-version-specific
allocation strategy, not a property of the code.

tamp_match_extend is now a pure value-returning static inline shared by the
prefilter and desktop match finders (the swar32 experiment gets its own
32-bit variant), with a small TAMP_MATCH_UPDATE macro for the
update-and-stop step. Measured cost on Cortex-M7: 253.0 KB/s vs 261.6
(~3%), still 1.31x the portable baseline, byte-identical output; noted in
the function comment and the benchmark row is updated. Neutral on 64-bit
hosts (20.1 vs 19.95 MB/s arm64). All C/Python tests pass; armv6m sizes
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…f-select

The sentence predated the explicit opt-in model and contradicted it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each row's Tamp options are now simply the compile-time flags a library
user passes to reproduce that build (dash = none/portable); which build a
given harness or component uses by default is that build system's concern,
documented in common.h and Kconfig.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The runner's ESP32 reset dance left DTR deasserted, and pico-sdk's USB-CDC
stdio only transmits while the host asserts DTR - RP2040 captures timed out
with no output. The runner now re-asserts DTR after the dance (harmless for
running ESP32 targets; re-verified against ESP32-S3).

The published RP2040 C numbers did not reproduce on current code or on
main's own code with today's toolchain (a same-day A/B measured main and
this branch within 1.3% of each other), so they evidently predate later
development on the #347 branch. Fresh row: compress 44,522 bytes/s
(bit-identical code to main on armv6m), decompress 1,150,900 bytes/s. The
A/B also validates the unconditional local-position window copy on M0+:
+1.3% enwik8 / +7.5% repetitive decompression vs main's copy loop, with
byte-identical output. The MicroPython native-module row is from the same
older measurement session and still needs re-measuring (requires reflashing
MicroPython firmware).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Commit 200d702 re-measured the row (44,522 B/s compress, ~1,150,900 B/s
decompress) and noted the published numbers did not reproduce, but only the
runner fix landed - the table itself was never updated. Re-verified on device
today: 44,522 B/s compress (exact match), 1,150,721 B/s decompress.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nting

Three changes to the decompression hot loop, all measured on-device with
byte-verified output:

- The OOB security check's first comparison (window_offset >= window_size)
  is provably dead: window_offset is a conf_window-bit extraction and can
  never reach window_size. Keep only the offset+size bound as a single
  subtract-compare (no underflow: match_size <= 30 << window_size >= 256).
  Same check in decode_extended_match.

- Literal path: finish all decompressor field updates before the
  unsigned-char stores to output/window, and stop re-reading *output after
  storing it. Char stores may alias the struct, so ordering them last avoids
  forced field reloads.

- Derive *output_written_size from the output cursor at function exit
  (TAMP_DECOMP_RETURN) instead of a per-token read-modify-write through a
  size_t pointer, which both costs a load/store per token and may alias the
  uint32_t decompressor fields on 32-bit targets. decode_rle /
  decode_extended_match lose the now-dead parameter.

Also add TAMP_FAST_OUTPUT_COPY (default: TAMP_ARMV7EM): word-at-a-time
TAMP_COPY_TO_OUTPUT via __builtin_memcpy. Window and output never overlap
and it never writes past out+count.

Measured enwik8-100KB decompression (compression unchanged on all):
  STM32H7B0 TAMP_ARMV7EM=1: 6,731,740 -> 7,172,057 bytes/s (+6.5%);
    repetitive workload 5,414 -> 4,764 us (+13.6%, mostly the word copy)
  STM32H7B0 portable:       6,574,000 -> 6,861,534 bytes/s (+4.4%)
  ESP32-S3 TAMP_ESP32=1:    1,987,000 -> 2,066,414 bytes/s (+4.0%)
  ESP32-S3 generic:         1,912,000 -> 1,977,339 bytes/s (+3.4%)

armv6m size: no-extended builds unchanged; extended decompressor +20 bytes.
The RP2040, ESP32, and ESP32-C3 table rows predate this change and need
re-measuring (boards not attached); update the four re-measured rows.

Validated: make c-test / c-test-embedded (also with the three TAMP_FAST_*
flags forced on host), full pytest suite, and on-device reference/vector/
stress PASS on STM32H7B0 and ESP32-S3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bare-metal Cortex-M0+/M4/M7 benchmark firmware run under qemu-system-arm
(MPS2 machines) with a custom TCG plugin counting executed instructions
per PC. Reports deterministic per-function insns/byte profiles,
count-annotated disassembly (--annotate), and exact A/B diffs (--compare);
every run memcmp-verifies output. Workload matches devices/BENCHMARKS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three structural changes, iterated against QEMU instruction counts and
validated on hardware:

- Window updates copy from the just-written output snapshot instead of
  window-to-window through tamp_window_copy: linear source, destination
  wrap only, no overlap reverse-copy. Inline macro on TAMP_ARMV7EM
  (TAMP_WINDOW_FROM_OUTPUT); out-of-line NOINLINE call on portable
  builds, where inlining regresses Cortex-M0+ register allocation.
  Resume completions keep the window-sourced copy (output only holds
  the match tail). ESP32 keeps its platform TAMP_WINDOW_COPY.
- Resume-skip handling costs one load and no stores per token in the
  common path; input-consumed accounting is derived from the input
  cursor at exit, mirroring the existing output-side deferral (the
  callback path passes the live count without touching the field).
- TAMP_FAST_DECODE_LOOP (default TAMP_ARMV7EM): checked-once inner
  loop. With >=4 input bytes and >=32 output bytes guaranteed and no
  callback/skip/extended/flush state pending, every mid-token
  exhaustion, output-full, and last_was_flush check on the classic
  path is provably dead; FLUSH and extended tokens break out to the
  careful loop. The OOB window-bounds check is retained. Costs +936 B
  text on ARMV7EM, +460 B if opted in on M0+ (off there by default).

Measured (enwik8 100 KB, w=10): STM32H7B0 @280 MHz 7.17 -> 11.04 MB/s
(+54%); RP2040 @125 MHz 1.15 -> 1.23 MB/s default, 1.52 MB/s (+32%)
with TAMP_FAST_DECODE_LOOP=1. QEMU insns/byte: M7 45.3 -> 31.1,
M4 47.5 -> 32.8, M0+ 67.8 -> 51.0. Compressor output and speed
unchanged; ESP32 object byte-identical apart from the (default-off)
fast loop. BENCHMARKS.md H7B0/RP2040 rows re-measured on device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@BrianPugh
BrianPugh marked this pull request as draft July 16, 2026 14:14
@BrianPugh

Copy link
Copy Markdown
Owner Author

will break this up into a few cleanuped PRs once optimization is settled.

BrianPugh and others added 2 commits July 16, 2026 10:46
The decompressor fuzz harness now derives input/output chunk sizes from
the fuzz data (sizes chosen around token boundaries: 1-byte chunks, the
17-byte max classic match, the 32-byte fast-loop output precondition,
the 241-byte max RLE run), so the suspend/resume state machines -
mid-token INPUT_EXHAUSTED, output-full skip_bytes/token_state resume,
header-byte stash - run against malicious data instead of only the
full-buffer happy path. A config bit also covers the custom-dictionary
(uninitialized-window) init path.

New fuzz/fuzz-matrix.sh (make fuzz-matrix) builds the harness in every
flag-gated decompressor configuration - portable, the full ARMV7EM
profile, TAMP_FAST_DECODE_LOOP alone, TAMP_WINDOW_FROM_OUTPUT with
portable copies, TAMP_EXTENDED=0, TAMP_USE_MEMSET=0, TAMP_ESP32, and
TAMP_ESP32 with the fast loop - replays the shared corpus as a
regression suite, then fuzzes each briefly. Previously only the
portable configuration ever saw malicious input; the ARMV7EM-profile
code that ships on M4/M7 was compiled out of the fuzz build.

Validated: 3.4M-run portable and 11.3M-run ARMV7EM malicious-input
campaigns, a 20-minute ARMV7EM round-trip campaign, and the full matrix,
all under ASAN+UBSan with zero findings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Host ASAN fuzzing proves the C decompressor paths memory-safe but cannot
see defects that only exist in the target binaries: codegen at -O3 for
armv6m/armv7em, unsigned plain char, 32-bit size_t. profile.py --replay
packs a fuzz corpus into a length-prefixed blob, and a replay firmware
(same config/chunk-byte scheme as fuzz/fuzz_decompressor.c) streams it
via semihosting file I/O through the decompressor on the emulated
M0+/M4/M7, with canary-fenced buffers checked after every entry and
faults reporting through the vector handlers.

Validated: 1,315-entry portable-campaign and 3,048-entry
ARMV7EM-campaign corpora replay clean on all three cores.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BrianPugh and others added 16 commits July 16, 2026 12:53
…onfig flags

Round 4+5 of the QEMU-profiled optimization campaign:

- Two-token unroll of the fast decode loop: one bounds check (>=8 input,
  >=64 output bytes) covers two token bodies. M4 -7.6%, M7 -5.6%.
- History-window mode (TAMP_HISTORY_WINDOW, default TAMP_ARMV7EM):
  on classic streams, once window_size contiguous output bytes exist the
  ring exactly mirrors the output tail, so an armed fast-loop variant
  serves matches from output history, skips every per-token ring store,
  and rebuilds the ring in bulk on exit. Seam-crossing matches (ring
  source straddling window_pos) bail uncommitted to the careful body;
  every armed exit reconstructs before anything reads the ring. Extended
  streams keep the plain loop: RLE/extended-match ring writes are
  truncated by format design, which breaks the output<->ring
  correspondence after every extended token. M4 -12.7%, M7 -12.4%
  (classic); extended unchanged. Costs +3.0KB text on ARMV7EM builds.
- TAMP_FIXED_WINDOW_BITS / TAMP_FIXED_LITERAL_BITS: opt-in compile-time
  pinning of the stream configuration; shifts become immediates and
  min_pattern_size constant-folds. Non-matching headers are rejected at
  configuration time. Another -1.3..-2.9% on v7em for fixed deployments.
- Fix TAMP_FAST_DECODE_LOOP=1 with TAMP_EXTENDED=0 failing to compile
  (fast loop referenced the extended-only token_state field); guarded
  via TAMP_PENDING_TOKEN_STATE.

Tests, per the every-fix-ships-a-test policy:
- c-compile-matrix (dependency of c-test): compiles the vendored trio
  under every documented flag combination, including the one that broke.
- ctests/history_round_trip.c + c-test-history (dependency of c-test):
  seam-crossing round-trips at w=8/10/12 across chunk sizes under
  ASan/UBSan, in history-on, history-byte-copy, and history-off builds;
  the byte-copy build provably detects the unsequenced output-copy bug
  found during bring-up.
- fuzz-matrix: five new configs (fixed_w10, fixed_w10_fastloop,
  no_extended_fastloop, v7em_history, history_classic).

QEMU insns/byte after this commit (enwik8 100KB w=10, classic): M7
45.3 -> 25.7 and M4 47.5 -> 26.4 cumulative for the campaign; M0+
default build unaffected by this commit. Verified: pytest, c-test(+new
gates), c-test-embedded, 13-config fuzz matrix, and adversarial corpus
replay on emulated M0+/M4/M7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…3 core

CORE_SYMBOL_PREFIXES was missing tamp_window_write_from_output_fn and
tamp_history_reconstruct, silently excluding out-of-line helper work
from the insns/byte metric (portable-build M0+ numbers were understated
by ~14.5 insns/byte since the helper landed; hardware A/Bs were
unaffected and remain the record of truth). Corrected current numbers:
m0plus 65.49, m4 26.59, m7 25.88 insns/byte (classic).

Add an m33 core (mps2-an505, Cortex-M33) with its secure-alias linker
script; measured 47.20 portable / 26.13 with the ARMV7EM profile, which
armv8m.main executes legally - a strong prior for RP2350.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1,524,088 -> 1,623,271 bytes/s decompression on-device (+6.5% from the
round-4 unroll; +41% total vs the default portable build's baseline).
Compression control unchanged; full on-device suite PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
11,039,000 -> 13,848,497 bytes/s on-device (+25.5% from rounds 4-5;
1.93x total vs the pre-campaign 7,172,057). Full on-device suite PASS;
compression unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…FILL)

A 64-bit reservoir over the fast decode loop replaces the per-token
byte-at-a-time refill with one unconditional 4-byte load every ~1-2
tokens. Applies to all three token-carrying loop variants (history
armed, un-armed, extended-plain). Default TAMP_ARMV7EM; compiles to
nothing without TAMP_FAST_DECODE_LOOP.

Writeback discipline on every loop exit: un-read surplus whole bytes
(rbits == 8*bytes_read - bits_consumed holds across pushback, so byte
alignment is automatic), mask the sub-rbits bits of the committed
32-bit buffer (they hold just-un-read data the next refill ORs over),
restore the >=25-bit fast-loop invariant with a checked refill. The
refill is required: an output-precondition exit can leave <25 bits and
the careful body decodes one token with no intervening refill. OOB
returns also write back first so input_consumed_size/bit_buffer stay
exact on malicious streams; ctests/history_round_trip.c gains two
hand-crafted OOB streams (un-armed + history-armed paths) asserting
(consumed-1)*8 - bit_buffer_pos equals the offending token's bit
offset, proven to fail without the writeback.

Measured: QEMU m4 -17.3% / m7 -16.2% insns/byte (classic);
STM32H7B0 13,848,497 -> 15,817,779 bytes/s (+14.2%), on-device suite
PASS. Opt-in datapoints for portable cores (with TAMP_FAST_DECODE_LOOP):
QEMU m0plus -6.9%, m33 -12.0%; unverified on hardware, so only ARMV7EM
defaults on. c-compile-matrix, c-test-history, and fuzz-matrix gain
reservoir-on/off configurations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
13,848,000 -> 15,154,000 bytes/s on-device. (An earlier pre-promotion
build of the same reservoir measured 15,818,000; the ~4% delta is code
layout/alignment, not instruction count - QEMU counts are identical.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each row now shows the text (incl. rodata) total of the vendored
objects built with that row's exact flags, so the speed/size trade of
every option is visible next to its throughput - e.g. TAMP_ARMV7EM=1
buys the STM32H7B0 2.2x decompression for 6,339 -> 17,007 bytes, while
the RP2040 fast-loop opt-in costs 760 bytes for +32%. Reproduce with
tools/benchmark-code-size.sh (make benchmark-code-sizes); the script
locates the arm-none-eabi, xtensa-esp-elf, and riscv32-esp-elf
toolchains and skips rows whose toolchain is missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The history-window armed path only runs on classic (non-extended)
streams, and it is the largest size contributor of the ARMV7EM profile
(~4.4 KB of Cortex-M7 decompressor .text). Extended-format deployments
gain nothing from the armed path, but disabling it is not free:
-DTAMP_HISTORY_WINDOW=0 measures ~4% more insns/byte on extended
streams (QEMU M7) since the history build's dedicated plain loop is
lost too. State both in common.h, and note in BENCHMARKS.md that the
benchmark workload is classic-format, so extended deployments should
expect smaller gains from the profile than the table shows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The history-window fast-loop mode was the single largest code-size
contributor of the ARMV7EM profile (~4.4 KB of decompressor .text on
Cortex-M7, now visible in the BENCHMARKS.md code-size column). Its
speedup only applies to classic (non-extended) streams. Decision: the
flash cost outweighs the classic-stream decompression gain, so the
flag, both history token bodies, tamp_history_reconstruct, the
dedicated round-trip test harness, and the history fuzz legs are all
removed. The STM32H7B0 benchmark row will be re-measured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Code size 17,007 -> 12,547 B; decompression 15,154,000 -> 12,549,000
bytes/s on the classic-format workload (the history mode's speedup was
classic-only); compression unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…aster on M7

With TAMP_RESERVOIR_REFILL the refill is already amortized to ~one per
1-2 tokens, so the unroll no longer buys anything: single-token loop
measured +3.1% decompression throughput on STM32H7B0 hardware
(12,549,000 -> 12,940,000 bytes/s) while cutting 988 B of
decompressor .text (ARMV7EM total 12,547 -> 11,559 B). QEMU m4 insn
counts prefer the unroll by ~4% (hardware-unverified); real-M7 wins
the tiebreak since it is the profile's verified reference.

The non-reservoir fast loop keeps its unroll: there the refill runs
per token, and un-unrolling measured +8.7% insns on the M0+ fastloop
build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RP2040_TAMP_OPT / TAMP_BENCH_DEFINES selects the tamp compile
definitions for A/B benchmark builds (mirroring TAMP_ESP32_OPT), so
the fastloop BENCHMARKS.md row is reproducible without editing
CMakeLists. Update the TAMP_ARMV7EM master-flag comment to the
current measured profile numbers (1.31x compression, 1.92x
decompression, ~5.2 KB flash vs portable on STM32H7B0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A/B on a real Pico (BOOTSEL-flashed both builds, full suite PASS):
stacking the 64-bit reservoir refill on TAMP_FAST_DECODE_LOOP=1 with
the now-single-token reservoir loop measures 1,623,587 -> 1,700,130
bytes/s enwik8 decompression (+4.7%, repetitive +3.2%) at 7,391 B vs
7,415 B total text - faster AND smaller, confirming the QEMU insn
prediction (-4.4%). Row, code-size script config, and the common.h
hardware-verification note updated; compression unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ACT_CAREFUL_BODY)

tamp_fast_decode_loop is now its own -O3 function (called once per
outer-loop iteration, cursors by reference), letting the surrounding
tamp_decompressor_decompress_cb - header parsing, resume/tail tokens,
extended dispatch glue, all cold in fast-loop builds - compile -Os.
New flag TAMP_COMPACT_CAREFUL_BODY (default TAMP_ARMV7EM, GCC-only);
without it the function is ALWAYS_INLINE'd back so non-profile builds
keep their exact shape (M0+ fastloop object within 4 B, RP2040 row
re-measured identical at 1,700,420 B/s, suite PASS).

ARMV7EM three-object total: 11,559 -> 10,687 B (-872). QEMU insns:
m4 classic -4.6%, m7 classic -0.9%, m4 extended -3.2%, m7 extended
+0.6%. Real H7B0: 12,874,983 B/s (-0.5%, within the known +/-4% M7
code-layout noise; row updated), compression unchanged, suite PASS.

Also: profile.py CORE_SYMBOL_PREFIXES gains tamp_fast_decode_loop
(and drops the removed tamp_history_reconstruct) so the extracted
loop stays visible in the core metric; compile-matrix legs for both
flag polarities; the shipping fuzz leg now carries
-DTAMP_COMPACT_CAREFUL_BODY=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lementation

Every platform that compiles the fast decode loop measured the 64-bit
reservoir as a win (STM32H7B0/M7 +14.2%, RP2040/M0+ +4.7% and smaller
code despite the __aeabi_ll* helpers, QEMU m33 -12% insns/byte), and no
shipped configuration used the non-reservoir path. Delete that path
(TAMP_FAST_TOKEN_BODY, refill_bit_buffer_unchecked,
decode_huffman_unchecked, the two-token unroll) and the flag;
TAMP_FAST_DECODE_LOOP is the single knob. The flag's measured numbers
move into the TAMP_FAST_DECODE_LOOP doc in common.h.

ARMV7EM and RP2040-fastloop objects are byte-identical before/after;
c-test passes with the fast loop forced on host under ASan/UBSan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review of the branch surfaced 10 findings; all applied:

- common.h: TAMP_ESP32 now counts in the find_best_match mutual-exclusion
  check (previously TAMP_ESP32 + an explicit TAMP_USE_*_MATCH compiled
  silently with the ESP32 extern winning). c-compile-matrix gains two
  must-NOT-compile regression configs.
- decompressor.c: delete dead TAMP_EXPERIMENT_NO_OOB scaffolding around
  the OOB check (defined nowhere; byte-identical objects on M7 and M0+).
- New make c-test-prefilter target + CI leg: the ARMV7EM profile's
  matcher was compile-checked but never behaviorally run on host.
- fuzz-matrix: v7em config now uses -DTAMP_ARMV7EM=1 instead of a
  hand-expanded flag list that would drift from the profile definition
  (proven codegen-identical); fuzz phase now runs configs with
  FUZZ_JOBS-bounded parallelism, per-config logs/artifact dirs (crash
  files previously landed unqualified in the repo root).
- New fuzz/decompressor_fuzz_case.h: single source of truth for the
  corpus byte contract, shared by the libFuzzer harness and the QEMU
  replay firmware (was hand-copied; proven bit-identical by hashing all
  4363 corpus entries through old and new drivers).
- CLAUDE.md: correct the TAMP_ARMV7EM profile numbers (1.31x/1.92x) and
  document all six chained flags.
- stm32h7b0 syscalls: one SYS_WRITE per _write via a cached ":tt" handle
  instead of chunked SYS_WRITE0 traps (verified on hardware).
- qemu-profiler plugin: inline ADD_U64 scoreboard counters instead of a
  per-insn helper callback (instruction counts proven identical).
- benchmark-code-size.sh: factor esp32c3 blocks into run_riscv_* helpers
  (output byte-identical).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant