From a09a55ce9cd9c65302d17d39906555b217fd5439 Mon Sep 17 00:00:00 2001 From: Peter Neiss Date: Thu, 25 Jun 2026 09:56:00 +0200 Subject: [PATCH 1/6] ci: run tests, examples and fuzz under ASan+UBSan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a BOUND_SANITIZE CMake option (AddressSanitizer + UndefinedBehaviorSanitizer, -fno-sanitize-recover=all so any finding is a hard failure) and a Clang-18 Debug Linux CI cell that builds the whole suite under it. The fast paths lean on hand-rolled 128-bit intermediates, signed-overflow detection and bit-shifts — exactly the UB UBSan catches — yet nothing exercised them in CI before. The sanitized cell additionally runs the property fuzzer under UBSan as a short fixed run (seed 1, 500 iters/property, ~90M checks, ~75s); the long sweep stays on the existing heavy cells. Suite, examples and fuzz are all clean under g++ sanitizers locally. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 19 +++++++++++++++++++ CMakeLists.txt | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1089a14..a91b37a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,11 @@ jobs: linux: name: linux ${{ matrix.cc }} ${{ matrix.build }}${{ matrix.note }} runs-on: ${{ matrix.os }} + # Harmless on non-sanitized cells; makes any ASan/UBSan finding a hard, + # stack-traced failure on the sanitizer cell. + env: + ASAN_OPTIONS: halt_on_error=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 strategy: fail-fast: false matrix: @@ -27,6 +32,10 @@ jobs: - { os: ubuntu-22.04, cc: gcc, ver: 12, build: Debug, extra: "-DBOUND_CXX20=ON", note: " (C++20)" } - { os: ubuntu-24.04, cc: gcc, ver: 14, build: Release, extra: "-DBOUND_MATH_FIXED=ON", note: " (CORDIC)", heavy: true } - { os: ubuntu-24.04, cc: gcc, ver: 14, build: Release, extra: "-DBOUND_MATH_FLOAT=ON", note: " (float)" } + # AddressSanitizer + UBSan. The fast paths lean on hand-rolled 128-bit + # intermediates, signed-overflow detection and bit-shifts — exactly the + # UB UBSan catches. Debug + Clang for the sharpest diagnostics. + - { os: ubuntu-24.04, cc: clang, ver: 18, build: Debug, extra: "-DBOUND_SANITIZE=ON", note: " (sanitizers)", san: true } # Native ARM64 (free for public repos): exercises a different ABI/codegen — # the Release cell runs the fuzz harness as a cross-arch bit-exactness check. - { os: ubuntu-24.04-arm, cc: gcc, ver: 14, build: Debug, note: " (arm64)" } @@ -73,6 +82,16 @@ jobs: cmake --build build --target bound_fuzz bench -j ./build/bound_fuzz + # Sanitized cell: run the property fuzzer under ASan+UBSan, but a short + # fixed run (seed 1, 500 iters/property ≈ 90M checks, ~75s) — sanitizers + # are ~2-10x, so the long sweep stays on the `heavy` cells above. + - name: Fuzz (sanitizers, short) + if: matrix.san + timeout-minutes: 8 + run: | + cmake --build build --target bound_fuzz -j + ./build/bound_fuzz 1 500 + windows: name: windows msvc ${{ matrix.arch }} ${{ matrix.build }} runs-on: ${{ matrix.os }} diff --git a/CMakeLists.txt b/CMakeLists.txt index 0603276..8687dd2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -103,6 +103,28 @@ if(BOUND_COVERAGE) target_link_options (bound_bound INTERFACE --coverage) endif() +#--------------------------------------------------------------------------- +# Sanitizers (opt-in; AddressSanitizer + UndefinedBehaviorSanitizer). +# The library leans on hand-rolled 128-bit intermediates, signed-overflow +# detection, and bit-shifts in the fast paths — exactly the UB UBSan catches. +# -fno-sanitize-recover=all makes any finding a hard failure (non-zero exit) +# so CI fails instead of merely logging. Applied to the bound INTERFACE so it +# propagates to every test/example/fuzz TU. GCC or Clang only. +#--------------------------------------------------------------------------- +option(BOUND_SANITIZE "Build tests with AddressSanitizer + UBSan" OFF) +if(BOUND_SANITIZE) + if(NOT CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + message(FATAL_ERROR "BOUND_SANITIZE requires GCC or Clang") + endif() + if(BOUND_COVERAGE) + message(FATAL_ERROR "BOUND_SANITIZE and BOUND_COVERAGE are mutually exclusive") + endif() + target_compile_options(bound_bound INTERFACE + -fsanitize=address,undefined -fno-sanitize-recover=all + -fno-omit-frame-pointer -g) + target_link_options(bound_bound INTERFACE -fsanitize=address,undefined) +endif() + enable_testing() include(FetchContent) From db9e10e56ce9fd4400027797d5382c6274ef828a Mon Sep 17 00:00:00 2001 From: Peter Neiss Date: Thu, 25 Jun 2026 10:04:11 +0200 Subject: [PATCH 2/6] ci: add a coverage job; cover the wrap-identity and non-finite edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the existing -DBOUND_COVERAGE target into CI as its own gcc-14 job that builds everything, runs the coverage target, prints the lcov summary, and uploads the HTML report — coverage was measurable but never tracked. Close two reachable gaps the report surfaced: - wrap on a grid spanning the whole u64 range hits apply_wrap's urange==0 fast path (span wraps to 0 in umax), where the wrap is the identity; asserted as a raw round-trip in test_wrap_overflow.cpp. - a real (double-backed) grid rejects NaN/+-inf with errc::not_finite; pinned in test_real_exact.cpp as a behavioral regression. Line coverage 98.1 -> 98.2%. The remaining lines are defensive traps (shadowed by earlier guards) and other-engine-only branches exercised by the float/CORDIC cells, not the default-engine coverage run. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++++ tests/test_real_exact.cpp | 21 +++++++++++++++++++++ tests/test_wrap_overflow.cpp | 20 ++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a91b37a..718a166 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,39 @@ jobs: cmake --build build --target bound_fuzz -j ./build/bound_fuzz 1 500 + coverage: + name: linux gcc coverage + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install toolchain + run: | + sudo apt-get update + sudo apt-get install -y ninja-build g++-14 lcov + echo "CC=gcc-14" >> "$GITHUB_ENV" + echo "CXX=g++-14" >> "$GITHUB_ENV" + + - name: Configure + run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DBOUND_COVERAGE=ON + + # Build everything first: the `coverage` target runs ctest, which includes + # the example tests — those must exist or ctest reports them "Not Run". + - name: Build + run: cmake --build build -j + + - name: Coverage + run: cmake --build build --target coverage + + - name: Summary + run: lcov --summary build/coverage.info + + - name: Upload HTML report + uses: actions/upload-artifact@v4 + with: + name: coverage-html + path: build/coverage/ + windows: name: windows msvc ${{ matrix.arch }} ${{ matrix.build }} runs-on: ${{ matrix.os }} diff --git a/tests/test_real_exact.cpp b/tests/test_real_exact.cpp index 59f393b..2019e44 100644 --- a/tests/test_real_exact.cpp +++ b/tests/test_real_exact.cpp @@ -315,4 +315,25 @@ TEST_CASE("over-fine real product deduces rational, stays exact", "[real][exact] REQUIRE(static_cast(*p) == rational{umax{1} << 34}); } +//--------------------------------------------------------------------------- +// A real (double-backed) target rejects non-finite assignments: NaN/inf can +// never be an on-grid value, so the store guard reports errc::not_finite rather +// than poisoning the raw double. Exercises the non-finite branch in +// store_checked for fp_raw storage. +//--------------------------------------------------------------------------- +TEST_CASE("real storage rejects non-finite assignment", "[real][error]") +{ + using R = bound<{{-8, 8}, notch<1, 65536>}, real>; + static_assert(std::is_same_v); + + auto threw_not_finite = [](auto&& fn) { + try { fn(); return false; } + catch (bnd::bound_error const& e) { return e.code == errc::not_finite; } + }; + + REQUIRE(threw_not_finite([]{ R x = std::nan(""); (void)x; })); + REQUIRE(threw_not_finite([]{ R x = std::numeric_limits::infinity(); (void)x; })); + REQUIRE(threw_not_finite([]{ R x = -std::numeric_limits::infinity(); (void)x; })); +} + #endif // !BND_MATH_FIXED diff --git a/tests/test_wrap_overflow.cpp b/tests/test_wrap_overflow.cpp index 9e05dc3..2be5552 100644 --- a/tests/test_wrap_overflow.cpp +++ b/tests/test_wrap_overflow.cpp @@ -79,6 +79,26 @@ TEST_CASE("wrap on a grid spanning more than imax", "[wrap][overflow]") REQUIRE(static_cast(b) == 0); } +TEST_CASE("wrap is identity on a grid spanning the whole u64 range", "[wrap][overflow]") +{ + // span == 2^64-1, so `urange = upper - lower + 1` wraps to 0 in umax: every + // u64 bit pattern is already in range, so the wrap is the identity and the + // value is stored verbatim. Exercises the urange==0 fast path in apply_wrap. + // Identity is a raw round-trip: lower is 0, so the stored raw is exactly the + // input reinterpreted as u64 (a negative input keeps its two's-complement bits + // — it is not reducible, since the whole u64 range is in-grid). + using All = bound<{0, 18446744073709551615ULL}, wrap>; + static_assert(std::is_same_v); + for (long long v : { 0LL, 123456789LL, -1LL, + std::numeric_limits::max(), + std::numeric_limits::min() }) + { + All b = v; + INFO("v=" << v); + REQUIRE(b.raw() == static_cast(v)); // identity, no reduction + } +} + TEST_CASE("wrap still correct on a small symmetric grid", "[wrap]") { using S = bound<{-3, 3}, wrap>; // range 7 From 01a5886d1f2669587b65ba6331d6669fc1b712bb Mon Sep 17 00:00:00 2001 From: Peter Neiss Date: Thu, 25 Jun 2026 10:11:35 +0200 Subject: [PATCH 3/6] fuzz: cross-engine differential oracle; sanitize the freestanding smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit determinism.md and the cmath engine note guarantee the cordic/dbl/flt transcendental engines agree to within ~one output notch on a shared snap grid, but nothing enforced it — the engine tests only pinned exact special values. Add prop_cross_engine: a randomized sweep asserting cordic agrees with both dbl and flt for sin/cos/tanh/atan and sqrt on [0,4]. The engines genuinely differ (~10% of inputs, measured) with a true spread of exactly one notch, so the property is non-vacuous; the 8-notch tolerance catches gross divergence while absorbing legitimate ±1-notch ties and binary32 rounding. Guarded on !BND_MATH_NO_FP (the fixed engine leaves only cordic — nothing to compare). Also apply the BOUND_SANITIZE flags to single_header_freestanding_smoke (it links no bound::bound, so the interface flags miss it) and run it in the sanitizer cell, so the -fno-exceptions / BND_NO_STRING error path is exercised under ASan+UBSan rather than only compiled. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 7 ++++++ CMakeLists.txt | 10 ++++++++ tests/fuzz.cpp | 50 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 718a166..fbc2b14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,13 @@ jobs: cmake --build build --target bound_fuzz -j ./build/bound_fuzz 1 500 + # Sanitized cell: actually RUN the freestanding smoke (the step above only + # builds it). Under BOUND_SANITIZE this target picks up ASan+UBSan, so the + # -fno-exceptions / BND_NO_STRING error path is exercised, not just compiled. + - name: Freestanding smoke run (sanitizers) + if: matrix.san + run: ./build/single_header_freestanding_smoke + coverage: name: linux gcc coverage runs-on: ubuntu-24.04 diff --git a/CMakeLists.txt b/CMakeLists.txt index 8687dd2..ed06031 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -319,6 +319,16 @@ target_include_directories(single_header_freestanding_smoke PRIVATE target_compile_definitions(single_header_freestanding_smoke PRIVATE BND_NO_STRING) target_compile_options(single_header_freestanding_smoke PRIVATE $<$:-fno-exceptions>) +# This target doesn't link bound::bound (it sees only single_include/), so the +# BOUND_SANITIZE interface flags don't reach it — apply them here so the +# -fno-exceptions error path gets exercised under ASan+UBSan too. +if(BOUND_SANITIZE) + target_compile_options(single_header_freestanding_smoke PRIVATE + -fsanitize=address,undefined -fno-sanitize-recover=all + -fno-omit-frame-pointer -g) + target_link_options(single_header_freestanding_smoke PRIVATE + -fsanitize=address,undefined) +endif() if(BOUND_MATH_FIXED) target_compile_definitions(single_header_freestanding_smoke PRIVATE BND_MATH_FIXED) endif() diff --git a/tests/fuzz.cpp b/tests/fuzz.cpp index 8dd36af..d5b4132 100644 --- a/tests/fuzz.cpp +++ b/tests/fuzz.cpp @@ -1457,6 +1457,53 @@ void run_props(fuzz_state& s, long iters, const char* name) guarded(s, [&]{ prop_range(s, iters); }); } +#ifndef BND_MATH_NO_FP +//--------------------------------------------------------------------------- +// Cross-engine differential oracle. +// +// The three transcendental engines (cordic / dbl / flt) are independent +// approximations. determinism.md and the cmath.hpp engine note guarantee they +// land within ~one output notch of each other on a shared snap grid (the +// table-maker's dilemma is the only divergence). Nothing enforced that bound — +// the engine tests only pinned exact special values. This sweep turns the +// guarantee into a property: on one grid, cordic must agree with both dbl and +// flt. Restricted to O(1)-output functions (sin/cos/tanh/atan, sqrt on [0,4]) +// so the binary32 `flt` engine's coarser precision still falls inside the notch +// budget. Only meaningful when the FP engines exist (BND_MATH_FIXED implies +// BND_MATH_NO_FP, leaving only cordic — nothing to compare). +//--------------------------------------------------------------------------- +void prop_cross_engine(fuzz_state& s, long iters) +{ + using A = bound<{{-8, 8}, notch<1, 16384>}, round_nearest | real>; // angle / general + using P = bound<{{0, 4}, notch<1, 16384>}, round_nearest | real>; // sqrt domain + s.current_grid = "cmath"; + s.current_prop = "cross_engine"; + // 8 output notches: comfortably catches a divergent/wrong engine while + // absorbing legitimate ±1-notch tie disagreement and binary32 rounding. + const rational tol{8, 16384}; + auto agree = [&](rational x, rational y) { return approx_le(x, y, tol); }; + + for (long i = 0; i < iters; ++i) + { + s.iter = i; + A a = A::from_raw(random_in_range_raw(s.rng)); + + FUZZ_REQUIRE(s, agree(rational{math::cordic::sin(a)}, rational{math::dbl::sin(a)})); + FUZZ_REQUIRE(s, agree(rational{math::cordic::sin(a)}, rational{math::flt::sin(a)})); + FUZZ_REQUIRE(s, agree(rational{math::cordic::cos(a)}, rational{math::dbl::cos(a)})); + FUZZ_REQUIRE(s, agree(rational{math::cordic::cos(a)}, rational{math::flt::cos(a)})); + FUZZ_REQUIRE(s, agree(rational{math::cordic::tanh(a)}, rational{math::dbl::tanh(a)})); + FUZZ_REQUIRE(s, agree(rational{math::cordic::tanh(a)}, rational{math::flt::tanh(a)})); + FUZZ_REQUIRE(s, agree(rational{math::cordic::atan(a)}, rational{math::dbl::atan(a)})); + FUZZ_REQUIRE(s, agree(rational{math::cordic::atan(a)}, rational{math::flt::atan(a)})); + + P p = P::from_raw(random_in_range_raw

(s.rng)); + FUZZ_REQUIRE(s, agree(rational{math::cordic::sqrt(p)}, rational{math::dbl::sqrt(p)})); + FUZZ_REQUIRE(s, agree(rational{math::cordic::sqrt(p)}, rational{math::flt::sqrt(p)})); + } +} +#endif // !BND_MATH_NO_FP + //--------------------------------------------------------------------------- // main //--------------------------------------------------------------------------- @@ -1519,6 +1566,9 @@ int main(int argc, char** argv) guarded(s, [&]{ prop_atan2(s, iters); }); guarded(s, [&]{ prop_extended_math(s, iters); }); guarded(s, [&]{ prop_wrap_fractional(s, iters); }); +#ifndef BND_MATH_NO_FP + guarded(s, [&]{ prop_cross_engine(s, iters); }); +#endif // Cross-grid arithmetic: mix grids of different lower/upper but same notch. guarded(s, [&]{ prop_cross_add, bound<{-100, 100}>>(s, iters, "u100+s100"); }); From f106f7cfb65c7251e55c7066a041b993592864cc Mon Sep 17 00:00:00 2001 From: Peter Neiss Date: Thu, 25 Jun 2026 10:20:05 +0200 Subject: [PATCH 4/6] ci: add performance regression gates (codegen guard + cachegrind) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bench harness is build-only — a benchmark has no pass/fail — so nothing caught a perf regression. The library's pitch is zero-overhead, autovec-friendly arithmetic; a silent de-optimization (a fast path falling back to the rational path, or losing vectorization) would pass CI today. Two deterministic gates, both immune to shared-runner noise: * Codegen guard (tests/perf_codegen.cpp + check_codegen.sh): compiles the arithmetic fast paths at -O2 and asserts via objdump that the scalar add stays call-free (no checked/rational/error fallback inlined in) and the loop vectorizes (packed integer add). Verified to fire on both regression modes. Registered as a labelled ctest, x86-64 GNU/Clang; runs across the linux matrix and is pinned in a dedicated perf job. * Cachegrind instruction-count gate (tests/perf_workload.cpp + check_perf.py): runs a small, deterministic, BND_PERF_SCALE-scaled workload under cachegrind and fails a >5% growth in retired instructions vs tests/perf_baseline.json. Bootstraps the baseline on first run (writes + passes, uploads it as an artifact to review and commit); active once the baseline is committed. The check script is validated end-to-end against a fake valgrind shim. Kept lean to respect the CI time budget: cachegrind runs only on the dedicated perf cell, on a ~1e6-op workload, with a 10-minute timeout. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 41 ++++++++++++++++++ CMakeLists.txt | 31 +++++++++++++ tests/check_codegen.sh | 38 ++++++++++++++++ tests/check_perf.py | 94 ++++++++++++++++++++++++++++++++++++++++ tests/perf_codegen.cpp | 43 ++++++++++++++++++ tests/perf_workload.cpp | 66 ++++++++++++++++++++++++++++ 6 files changed, 313 insertions(+) create mode 100755 tests/check_codegen.sh create mode 100755 tests/check_perf.py create mode 100644 tests/perf_codegen.cpp create mode 100644 tests/perf_workload.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbc2b14..1a0268b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,6 +132,47 @@ jobs: name: coverage-html path: build/coverage/ + perf: + name: linux gcc perf gate + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install toolchain + run: | + sudo apt-get update + sudo apt-get install -y ninja-build g++-14 valgrind + echo "CC=gcc-14" >> "$GITHUB_ENV" + echo "CXX=g++-14" >> "$GITHUB_ENV" + + - name: Configure + run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release + + # codegen guard (call-free + vectorized fast paths) runs as a labelled + # ctest; it also runs across the linux matrix above, but pinning it here + # keeps the perf signals in one place. + - name: Codegen guard + run: ctest --test-dir build --output-on-failure -L perf + + # Cachegrind instruction-count gate. Deterministic, so stable on shared + # runners. If tests/perf_baseline.json is absent the script bootstraps it + # (writes + passes) and uploads it below for review/commit; once committed, + # this gate fails a >5% instruction-count regression. + - name: Cachegrind gate + timeout-minutes: 10 + run: | + cmake --build build --target perf_workload -j + python3 tests/check_perf.py \ + --binary build/perf_workload \ + --baseline tests/perf_baseline.json \ + --key integer_qformat --tol 0.05 + + - name: Upload perf baseline + uses: actions/upload-artifact@v4 + with: + name: perf-baseline + path: tests/perf_baseline.json + windows: name: windows msvc ${{ matrix.arch }} ${{ matrix.build }} runs-on: ${{ matrix.os }} diff --git a/CMakeLists.txt b/CMakeLists.txt index ed06031..15191aa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -354,6 +354,37 @@ if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") target_compile_definitions(single_header_nofp_smoke PRIVATE BND_MATH_NO_FP) endif() +#--------------------------------------------------------------------------- +# Performance regression gates (label "perf"; excluded from the normal unit run) +# +# * codegen guard — compiles the arithmetic fast paths at -O2 and asserts via +# objdump that the scalar path stays call-free and the loop vectorizes. Pure +# disassembly inspection (x86-64 mnemonics), so it is x86-64 GNU/Clang only; +# it compiles independently of the build type, so coverage/sanitizer flags on +# the bound interface don't perturb it. +# * cachegrind instruction-count gate — see tests/check_perf.py; opt-in via the +# perf CI cell, which has valgrind. +#--------------------------------------------------------------------------- +if(UNIX AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang" + AND CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|amd64|AMD64") + add_test(NAME codegen_guard + COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tests/check_codegen.sh + ${CMAKE_CXX_COMPILER} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/perf_codegen.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CXX_STANDARD}) + set_tests_properties(codegen_guard PROPERTIES LABELS "perf") +endif() + +# Dedicated, deterministic perf workload for the cachegrind gate. Small fixed +# work scaled by BND_PERF_SCALE (compile-time), kept lean so cachegrind (~20-50x) +# stays inside the CI budget. EXCLUDE_FROM_ALL — built only by the perf cell. +add_executable(perf_workload EXCLUDE_FROM_ALL tests/perf_workload.cpp) +target_link_libraries(perf_workload PRIVATE bound::bound) +if(WIN32 AND CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + target_link_options(perf_workload PRIVATE -Wl,--allow-multiple-definition) +endif() + #--------------------------------------------------------------------------- # Install / package config # diff --git a/tests/check_codegen.sh b/tests/check_codegen.sh new file mode 100755 index 0000000..30d7d2a --- /dev/null +++ b/tests/check_codegen.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Codegen guard: compile tests/perf_codegen.cpp at -O2 and assert the arithmetic +# fast paths did not regress — see that file's header for the rationale. +# +# Usage: check_codegen.sh +# Exits non-zero (with the offending disassembly) if either invariant fails. +set -euo pipefail + +CXX="${1:?compiler}"; SRC="${2:?source}"; INC="${3:?include dir}"; STD="${4:-23}" + +OBJ="$(mktemp --suffix=.o)" +trap 'rm -f "$OBJ"' EXIT + +# Force -O2 regardless of the project's build type: this guards *optimized* +# codegen, which is what ships and what a refactor can pessimize. +"$CXX" -std="c++${STD}" -O2 -I "$INC" -c "$SRC" -o "$OBJ" + +DIS="$(objdump -dC --no-show-raw-insn "$OBJ")" + +fail() { echo "CODEGEN GUARD FAILED: $1"; echo "----- disassembly -----"; echo "$2"; exit 1; } + +# (1) The scalar fast path must be call-free: a `call` here means the add fell +# back to the checked / error-handler / rational path instead of inlining. +fast="$(printf '%s\n' "$DIS" | sed -n '/:/,/\bret\b/p')" +[ -n "$fast" ] || fail "could not find bnd_perf_add_fast in the disassembly" "$DIS" +if printf '%s\n' "$fast" | grep -qE '\bcall[a-z]*\b'; then + fail "bnd_perf_add_fast contains a call — the integer fast path is no longer fully inlined" "$fast" +fi + +# (2) The loop must still vectorize: a packed-integer add (SSE2 paddd/paddq or +# AVX vpaddd/vpaddq) proves the fast path stayed autovectorizable. +loop="$(printf '%s\n' "$DIS" | sed -n '/:/,/^$/p')" +[ -n "$loop" ] || fail "could not find bnd_perf_add_loop in the disassembly" "$DIS" +if ! printf '%s\n' "$loop" | grep -qE '\b(paddd|paddq|vpaddd|vpaddq)\b'; then + fail "bnd_perf_add_loop did not vectorize (no packed integer add)" "$loop" +fi + +echo "codegen guard OK: scalar fast path is call-free and the loop vectorizes" diff --git a/tests/check_perf.py b/tests/check_perf.py new file mode 100755 index 0000000..eb585d3 --- /dev/null +++ b/tests/check_perf.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Cachegrind instruction-count regression gate. + +Runs a workload binary under `valgrind --tool=cachegrind`, reads the total +retired-instruction count (Ir) from the cachegrind output's `summary:` line, and +compares it to a committed baseline within a tolerance. Instruction counts are +deterministic regardless of host load, so this gives a stable regression signal +on noisy shared CI runners where wall-clock timing would be useless. + +Bootstrap: if the baseline file does not exist, this writes it from the current +run and exits 0 with a notice — so the first CI run produces the baseline as an +artifact to review and commit. Subsequent runs gate against it. + +Usage: + check_perf.py --binary build/perf_workload --baseline tests/perf_baseline.json + [--key integer_qformat] [--tol 0.05] [--update] +""" +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile + + +def run_cachegrind(binary: str) -> int: + """Run the binary under cachegrind and return total Ir (instructions).""" + with tempfile.TemporaryDirectory() as td: + out = os.path.join(td, "cg.out") + cmd = [ + "valgrind", "--tool=cachegrind", + "--cache-sim=no", "--branch-sim=no", # Ir only: faster, fewer moving parts + f"--cachegrind-out-file={out}", + binary, + ] + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + sys.stderr.write(proc.stderr) + raise SystemExit(f"valgrind exited {proc.returncode}") + # The out file carries a `summary: ` line; that total is the stable + # signal. (cachegrind's stderr also prints "I refs: N" but with commas.) + text = open(out, encoding="utf-8", errors="replace").read() + m = re.search(r"^summary:\s+(\d+)", text, re.MULTILINE) + if not m: + raise SystemExit("could not find a 'summary:' line in cachegrind output") + return int(m.group(1)) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--binary", required=True) + ap.add_argument("--baseline", required=True) + ap.add_argument("--key", default="default", + help="workload key in the baseline JSON") + ap.add_argument("--tol", type=float, default=0.05, + help="allowed fractional growth before failing (default 5%%)") + ap.add_argument("--update", action="store_true", + help="overwrite the baseline entry with the measured value") + args = ap.parse_args() + + ir = run_cachegrind(args.binary) + print(f"[perf] {args.key}: Ir = {ir:,}") + + baseline = {} + if os.path.exists(args.baseline): + with open(args.baseline, encoding="utf-8") as f: + baseline = json.load(f) + + if args.update or args.key not in baseline: + baseline[args.key] = ir + with open(args.baseline, "w", encoding="utf-8") as f: + json.dump(baseline, f, indent=2, sort_keys=True) + f.write("\n") + reason = "updated" if args.update else "bootstrapped (no prior entry)" + print(f"[perf] baseline {reason}: {args.baseline} [{args.key}] = {ir:,}") + print("[perf] review and commit this baseline; the gate is active once it exists.") + return 0 + + ref = int(baseline[args.key]) + limit = int(ref * (1.0 + args.tol)) + growth = (ir - ref) / ref * 100.0 + print(f"[perf] baseline = {ref:,} limit = {limit:,} (+{args.tol*100:.0f}%) " + f"delta = {growth:+.2f}%") + if ir > limit: + print(f"[perf] FAIL: {args.key} grew {growth:+.2f}% (> {args.tol*100:.0f}% tolerance)") + print("[perf] If this is an intentional change, re-run with --update and commit.") + return 1 + print(f"[perf] OK: {args.key} within tolerance") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/perf_codegen.cpp b/tests/perf_codegen.cpp new file mode 100644 index 0000000..e80cc04 --- /dev/null +++ b/tests/perf_codegen.cpp @@ -0,0 +1,43 @@ +// Codegen guard for the arithmetic fast paths. +// +// bound's headline claim is zero-overhead, autovec-friendly arithmetic: when +// every operand is integer-aligned on the same kind of grid, `a + b` must lower +// to a plain machine add — no branch into the checked/clamp/rational fallback, +// and a loop over it must still vectorize. A refactor (or a stray runtime check) +// can silently break either property while every functional test still passes. +// +// This TU is never run. It is compiled at -O2 and inspected with objdump by +// tests/check_codegen.sh, which asserts: +// * add_fast contains no `call` — the integer fast path stayed fully inlined, +// with no fallback to the error handler / rational path leaking in; +// * add_loop contains a packed-integer add — the fast path still autovectorizes. +// +// Both functions are extern "C" so the symbol names survive un-mangled and the +// disassembly is easy to slice. x86-64 GNU/Clang only (see CMakeLists guard). + +#include "bound/bound.hpp" +#include + +using namespace bnd; + +// A plain integer grid: native integer storage, integer-aligned. `x + y` widens +// the grid type but stays on the integer fast path. +using I = bound<{-1000000, 1000000}>; + +// Scalar fast path: must be a bare integer add, no call into any fallback. +extern "C" long bnd_perf_add_fast(long a, long b) +{ + I x = I::from_raw(static_cast(a)); + I y = I::from_raw(static_cast(b)); + return static_cast((x + y).raw()); +} + +// Vectorizable fast path: the same add over arrays must emit packed SIMD. +extern "C" void bnd_perf_add_loop(const long* __restrict a, + const long* __restrict b, + long* __restrict out, std::size_t n) +{ + for (std::size_t i = 0; i < n; ++i) + out[i] = static_cast((I::from_raw(static_cast(a[i])) + + I::from_raw(static_cast(b[i]))).raw()); +} diff --git a/tests/perf_workload.cpp b/tests/perf_workload.cpp new file mode 100644 index 0000000..b071941 --- /dev/null +++ b/tests/perf_workload.cpp @@ -0,0 +1,66 @@ +// Deterministic perf workload for the cachegrind instruction-count gate +// (tests/check_perf.py). It runs a fixed amount of representative bound +// arithmetic and prints a checksum; check_perf.py runs it under +// `valgrind --tool=cachegrind` and compares the total retired-instruction count +// (Ir) against tests/perf_baseline.json within a tolerance. Instruction counts +// are deterministic regardless of host load, so this is a stable signal even on +// noisy shared CI runners — unlike wall-clock timing. +// +// The work is intentionally small and scaled by BND_PERF_SCALE so cachegrind +// (~20-50x slowdown) stays well inside the CI time budget. Bump the scale for a +// deeper local run; the baseline is captured at scale 1. +// +// Everything funnels into a volatile sink so the optimizer can't elide the work, +// and the input sequence is a fixed integer recurrence (no RNG) so the +// instruction count is byte-for-byte reproducible. + +#include "bound/bound.hpp" + +#include +#include + +#ifndef BND_PERF_SCALE +#define BND_PERF_SCALE 1 +#endif + +using namespace bnd; + +namespace +{ + using I = bound<{-1000000, 1000000}>; // integer fast path + using Q = bound<{{-1000, 1000}, notch<1, 16>}>; // Q-format (fixed-point) fast path + + volatile std::int64_t g_sink = 0; // defeat dead-code elimination +} + +int main() +{ + constexpr long base = 200000; // ~1e6 ops at scale 1 + constexpr long iters = base * static_cast(BND_PERF_SCALE); + + std::int64_t acc = 0; + std::int64_t x = 1; // deterministic recurrence + + for (long i = 0; i < iters; ++i) + { + // a cheap LCG step keeps the operands varied but fully deterministic + x = x * 6364136223846793005LL + 1442695040888963407LL; + const long a = static_cast((x >> 33) % 1000000) - 500000; + const long b = static_cast((x >> 11) % 1000000) - 500000; + + // integer fast paths: add (widening) and the raw round-trip + I ia = I::from_raw(static_cast(a)); + I ib = I::from_raw(static_cast(b)); + acc += static_cast((ia + ib).raw()); + + // Q-format fast path: same-grid add on a fractional notch + Q qa = Q::from_raw(static_cast(a % 16001)); + Q qb = Q::from_raw(static_cast(b % 16001)); + acc ^= static_cast((qa + qb).raw()); + } + + g_sink = acc; + std::printf("perf_workload scale=%d sink=%lld\n", + static_cast(BND_PERF_SCALE), static_cast(g_sink)); + return 0; +} From 8c08c8ee99190af5ed76e3957dacfeee78b4cfcd Mon Sep 17 00:00:00 2001 From: Peter Neiss Date: Thu, 25 Jun 2026 10:26:55 +0200 Subject: [PATCH 5/6] ci: make the codegen guard compiler-version-robust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first CI run failed the codegen guard on GCC 14: it doesn't autovectorize the loop at -O2 while GCC 15 (local) does, so asserting a packed SIMD add was compiler-version-flaky. Demote vectorization to an informational note. Keep the robust, compiler-stable invariant — both fast paths must be call-free — and apply it to the loop body too. (Note: bound's rational arithmetic on compile-time grids constexpr-folds to call-free code, so this guards mainly against a fast path calling into the error handler / a non-inlined helper, i.e. "no throw in the inner loop"; the cachegrind instruction-count gate remains the fine-grained perf signal.) Co-Authored-By: Claude Opus 4.8 --- tests/check_codegen.sh | 37 ++++++++++++++++++++++--------------- tests/perf_codegen.cpp | 8 ++++---- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/tests/check_codegen.sh b/tests/check_codegen.sh index 30d7d2a..dd60b47 100755 --- a/tests/check_codegen.sh +++ b/tests/check_codegen.sh @@ -19,20 +19,27 @@ DIS="$(objdump -dC --no-show-raw-insn "$OBJ")" fail() { echo "CODEGEN GUARD FAILED: $1"; echo "----- disassembly -----"; echo "$2"; exit 1; } -# (1) The scalar fast path must be call-free: a `call` here means the add fell -# back to the checked / error-handler / rational path instead of inlining. -fast="$(printf '%s\n' "$DIS" | sed -n '/:/,/\bret\b/p')" -[ -n "$fast" ] || fail "could not find bnd_perf_add_fast in the disassembly" "$DIS" -if printf '%s\n' "$fast" | grep -qE '\bcall[a-z]*\b'; then - fail "bnd_perf_add_fast contains a call — the integer fast path is no longer fully inlined" "$fast" -fi - -# (2) The loop must still vectorize: a packed-integer add (SSE2 paddd/paddq or -# AVX vpaddd/vpaddq) proves the fast path stayed autovectorizable. +# The gate: both fast paths must be call-free. A `call` means the add fell back +# to the checked / error-handler / rational path instead of inlining to a bare +# machine add — the regression this guard exists to catch (cf. the cross-grid +# addition-dispatch bug, where an operand silently dropped to the value path). +# Checked on both the scalar and the loop body, so the fallback is caught +# whether or not the loop happens to vectorize. +for fn in bnd_perf_add_fast bnd_perf_add_loop; do + body="$(printf '%s\n' "$DIS" | sed -n "/<$fn>:/,/^\$/p")" + [ -n "$body" ] || fail "could not find $fn in the disassembly" "$DIS" + if printf '%s\n' "$body" | grep -qE '\bcall[a-z]*\b'; then + fail "$fn contains a call — the integer fast path is no longer fully inlined" "$body" + fi +done + +# Vectorization is reported but NOT gated: whether the loop autovectorizes at -O2 +# varies by compiler version (e.g. GCC 14 vs 15 on the same loop), so it is too +# brittle to fail on. A packed-integer add (SSE2 paddd/paddq, AVX vpaddd/vpaddq) +# means it still vectorizes; its absence is a soft heads-up, not a failure. loop="$(printf '%s\n' "$DIS" | sed -n '/:/,/^$/p')" -[ -n "$loop" ] || fail "could not find bnd_perf_add_loop in the disassembly" "$DIS" -if ! printf '%s\n' "$loop" | grep -qE '\b(paddd|paddq|vpaddd|vpaddq)\b'; then - fail "bnd_perf_add_loop did not vectorize (no packed integer add)" "$loop" +if printf '%s\n' "$loop" | grep -qE '\b(paddd|paddq|vpaddd|vpaddq)\b'; then + echo "codegen guard OK: fast paths are call-free; loop vectorizes (packed add)" +else + echo "codegen guard OK: fast paths are call-free; NOTE: loop did not vectorize at -O2 (informational)" fi - -echo "codegen guard OK: scalar fast path is call-free and the loop vectorizes" diff --git a/tests/perf_codegen.cpp b/tests/perf_codegen.cpp index e80cc04..9de8123 100644 --- a/tests/perf_codegen.cpp +++ b/tests/perf_codegen.cpp @@ -7,10 +7,10 @@ // can silently break either property while every functional test still passes. // // This TU is never run. It is compiled at -O2 and inspected with objdump by -// tests/check_codegen.sh, which asserts: -// * add_fast contains no `call` — the integer fast path stayed fully inlined, -// with no fallback to the error handler / rational path leaking in; -// * add_loop contains a packed-integer add — the fast path still autovectorizes. +// tests/check_codegen.sh, which asserts that both functions are call-free — i.e. +// the integer fast path stayed fully inlined to a bare machine add, with no +// fallback to the error handler / rational path leaking in. Whether add_loop +// autovectorizes is reported but not gated (it varies by compiler version). // // Both functions are extern "C" so the symbol names survive un-mangled and the // disassembly is easy to slice. x86-64 GNU/Clang only (see CMakeLists guard). From 32540858fd2b31f5d652682b8a08a58e633cc9df Mon Sep 17 00:00:00 2001 From: Peter Neiss Date: Thu, 25 Jun 2026 10:37:33 +0200 Subject: [PATCH 6/6] ci: commit perf baseline to arm the cachegrind gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captured from the perf cell's first run on this branch (gcc-14, ubuntu-24.04): integer_qformat = 9,793,323 retired instructions (Ir). With the baseline present, check_perf.py now gates — a >5% instruction-count regression fails CI instead of bootstrapping. Refresh with `check_perf.py --update` when a change legitimately moves it. Co-Authored-By: Claude Opus 4.8 --- tests/perf_baseline.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/perf_baseline.json diff --git a/tests/perf_baseline.json b/tests/perf_baseline.json new file mode 100644 index 0000000..94bf5c1 --- /dev/null +++ b/tests/perf_baseline.json @@ -0,0 +1,3 @@ +{ + "integer_qformat": 9793323 +}