diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..773f33a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,103 @@ +# EdgeVector CI: the full validation matrix on every push/PR. +# +# Six jobs: Linux gcc, Linux clang, native arm64 (real ARM silicon - also +# runs the clustered benchmark so ARM performance numbers are measured, not +# emulated), Windows MinGW, ASan+UBSan, and TSan on the concurrency suite. +# Every suite runs in both the assert-enabled and -DNDEBUG configurations. +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + linux-gcc: + name: Linux x86-64 (gcc) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Test (debug) + run: make -C tests run + - name: Test (release, -DNDEBUG) + run: make -C tests run-release + + linux-clang: + name: Linux x86-64 (clang) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Test (debug) + run: make -C tests run CXX=clang++ + - name: Test (release, -DNDEBUG) + run: make -C tests run-release CXX=clang++ + + linux-arm64: + name: Linux arm64 (native silicon) + runs-on: ubuntu-24.04-arm + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - name: Test (debug) + run: make -C tests run + - name: Test (release, -DNDEBUG) + run: make -C tests run-release + - name: Benchmark on real ARM silicon (clustered scenario) + run: | + make -C tests benchmark_100k + cd tests && ./benchmark_100k clustered | tee bench_arm64.md + - uses: actions/upload-artifact@v4 + with: + name: bench-arm64 + path: tests/bench_arm64.md + + windows-mingw: + name: Windows (MinGW-w64) + runs-on: windows-latest + timeout-minutes: 40 + defaults: + run: + shell: msys2 {0} + steps: + - uses: actions/checkout@v4 + - uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: false + install: mingw-w64-ucrt-x86_64-gcc make + - name: Test (debug) + run: make -C tests run + - name: Test (release, -DNDEBUG) + run: make -C tests run-release + + asan-ubsan: + name: ASan + UBSan + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + - name: Test under AddressSanitizer + UndefinedBehaviorSanitizer + run: > + make -C tests run + CXXFLAGS="-std=c++17 -O1 -g -march=native + -fsanitize=address,undefined -fno-sanitize-recover=all + -Wall -Wextra -Werror" + + tsan: + name: ThreadSanitizer (concurrent build + queries) + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + - name: Lower ASLR entropy (TSan is incompatible with 32-bit mmap_rnd_bits) + run: sudo sysctl vm.mmap_rnd_bits=28 + - name: HNSW suite under ThreadSanitizer + run: | + cd tests + g++ -std=c++17 -O1 -g -march=native -fsanitize=thread \ + -Wall -Wextra -Werror -I../include \ + test_hnsw_graph.cpp -o test_hnsw_graph_tsan + ./test_hnsw_graph_tsan diff --git a/README.md b/README.md index 534bbce..f150922 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # EdgeVector +[![CI](https://github.com/JonathanKash/EdgeVector/actions/workflows/ci.yml/badge.svg)](https://github.com/JonathanKash/EdgeVector/actions/workflows/ci.yml) + A header-only C++17 vector search library for edge devices: **binary quantization + HNSW + memory-mapped storage** in three headers, ~2,000 lines, zero dependencies — with a two-stage retrieval pipeline that reaches @@ -34,10 +36,11 @@ zero dependencies — with a two-stage retrieval pipeline that reaches - **Learned ITQ rotation** for anisotropic (real-embedding-shaped) data: +21 recall points at the same beam width, or the same accuracy at a fraction of the compute — while preserving cosine exactly -- **Validated on Linux (POSIX mmap), Windows (MinGW-w64), and AArch64** - (all suites pass under QEMU emulation with native arm64 g++; the Hamming - kernel compiles to NEON `cnt`), debug and `-DNDEBUG` release, always under - `-O3 -Wall -Wextra -Werror` +- **CI-validated on every push** across Linux gcc + clang, Windows + (MinGW-w64), and **native arm64 silicon** — where the benchmark runs on + real ARM hardware: 0.995 float-recall at 166 µs/query (~6,000 QPS, + 1 thread) — plus ASan/UBSan on all suites and **ThreadSanitizer on the + concurrent build**, debug and `-DNDEBUG`, always `-Wall -Wextra -Werror` ## The accuracy architecture @@ -356,12 +359,13 @@ hostile-file rejection, end-to-end recall gain), concurrent construction the full referential-integrity sweep and the recall gate), and the zero-allocation proof for all three search modes. -**AArch64:** `tests/arm64.Dockerfile` reproduces the ARM validation — all -five suites compiled by native arm64 g++ 13 at `-march=armv8-a -Werror` and -executed under QEMU user-mode emulation, with the Hamming kernel confirmed to -compile to NEON `cnt` (vector popcount) instructions. Emulated timings are -meaningless, so ARM *performance* claims wait for real silicon; correctness — -including bit-identical ITQ training results vs x86-64 — is validated. +**AArch64:** validated on **real arm64 silicon** in CI on every push (the +Hamming kernel compiles to NEON `cnt`), with the clustered benchmark run on +hardware and uploaded as an artifact. Measured there (4-core runner, single- +thread queries): 0.995 float-exact recall@10 at 166 µs/query, 4-thread build +of 100k vectors in 9.5 s, slot reclaim 1.8 ms, graph load 0.018 s — and every +recall figure bit-identical to x86-64. `tests/arm64.Dockerfile` reproduces +the ARM run locally under QEMU when no ARM hardware is at hand. ## Status and roadmap @@ -375,13 +379,7 @@ v0.8. Known limitations, in priority order: an mmap'ed block means writing and remapping a larger file 2. **Highly selective filters degrade** toward a scan of the reachable graph (true of every filtered-HNSW implementation; documented, not hidden) -3. **ARM validated for correctness, not yet for performance** — all suites - pass on aarch64 under QEMU emulation (including the 4-thread build and - query tests) with NEON popcount codegen confirmed, but latency/QPS - numbers on real ARM silicon are still pending; QEMU on an x86 host also - only partially exercises ARM's weaker memory model (the build's - correctness rests on mutex ordering, not x86 TSO, by design) -4. **Parallel builds are nondeterministic** in link structure (insertion +3. **Parallel builds are nondeterministic** in link structure (insertion order interleaves); use serial `insert()` or `insert_batch(..., 1)` when bit-reproducible graphs matter diff --git a/tests/test_hnsw_graph.cpp b/tests/test_hnsw_graph.cpp index 6448101..0cd6829 100644 --- a/tests/test_hnsw_graph.cpp +++ b/tests/test_hnsw_graph.cpp @@ -29,9 +29,11 @@ // --------------------------------------------------------------------------- // Global allocation counter. Every path into the C++ free store goes through // these replacements, so a zero delta across search() calls is hard evidence -// of a zero-allocation query path, not a code-reading claim. +// of a zero-allocation query path, not a code-reading claim. Atomic because +// worker threads allocate (their contexts) during concurrent-build tests. // --------------------------------------------------------------------------- -static std::size_t g_new_calls = 0; +#include +static std::atomic g_new_calls{0}; // The malloc/free pairing below is the canonical way to replace the global // allocator; GCC 13+'s -Wmismatched-new-delete cannot see that these are the diff --git a/tests/test_integration.cpp b/tests/test_integration.cpp index e71d5b8..8c326ac 100644 --- a/tests/test_integration.cpp +++ b/tests/test_integration.cpp @@ -27,7 +27,8 @@ // Counting operator new (same shim as test_hnsw_graph.cpp): proves the search // path stays allocation-free when reading vectors straight out of the mapping. -static std::size_t g_new_calls = 0; +#include +static std::atomic g_new_calls{0}; // See test_hnsw_graph.cpp: GCC 13+'s -Wmismatched-new-delete misfires on the // canonical malloc/free allocator replacement; silenced for the shim only.