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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)" }
Expand Down Expand Up @@ -73,6 +82,97 @@ 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

# 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
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/

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 }}
Expand Down
63 changes: 63 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -297,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
$<$<CXX_COMPILER_ID:GNU,Clang>:-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()
Expand All @@ -322,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
#
Expand Down
45 changes: 45 additions & 0 deletions tests/check_codegen.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/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 <cxx> <source> <include-dir> <std>
# 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; }

# 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 '/<bnd_perf_add_loop>:/,/^$/p')"
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
94 changes: 94 additions & 0 deletions tests/check_perf.py
Original file line number Diff line number Diff line change
@@ -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: <Ir>` 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())
Loading
Loading