Faster match finding and decompression on Cortex-M7-class targets#349
Draft
BrianPugh wants to merge 40 commits into
Draft
Faster match finding and decompression on Cortex-M7-class targets#349BrianPugh wants to merge 40 commits into
BrianPugh wants to merge 40 commits into
Conversation
BrianPugh
force-pushed
the
stm32h7-optimizations
branch
from
July 15, 2026 16:40
b8ff1a5 to
b8a10ba
Compare
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
force-pushed
the
stm32h7-optimizations
branch
from
July 15, 2026 16:57
b8a10ba to
869c4cc
Compare
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
force-pushed
the
stm32h7-optimizations
branch
from
July 15, 2026 17:07
53e35aa to
93a4d80
Compare
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
marked this pull request as draft
July 16, 2026 14:14
Owner
Author
|
will break this up into a few cleanuped PRs once optimization is settled. |
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #348 (uses its on-device harness for all measurements; retarget to
mainafter 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 showedfind_best_matchis 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, withTAMP_USE_EMBEDDED_MATCH=1as the escape hatch andTAMP_USE_PREFILTER_MATCH=1to 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_copyno-wrap fast path;refill_bit_bufferon 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
__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.__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-sizechecked). An RP2040 on-device A/B is a good follow-up.Measured but rejected
32-bit SWAR narrowing (0.93x on M7 — 6 positions per 2 loads amortizes worse than 14),
__usub8/__selSIMD32 (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
TAMP_USE_DESKTOP_MATCH, andTAMP_USE_EMBEDDED_MATCHconfigurations; compressed output remains byte-identical to the v1 reference in all of them.make c-test,make c-test-embedded, anduv run pytest(143 tests + 267 subtests, includes dataset regression through the Cython build) all pass.🤖 Generated with Claude Code