From dd16bb9ac4b6a01da04cfafb59f7fb06fec59641 Mon Sep 17 00:00:00 2001 From: gouyt13clear Date: Tue, 29 Sep 2026 13:54:59 +0800 Subject: [PATCH 1/3] Reduce sanitizer runtime for graph search tests --- tests/unit/rabitqlib/index/qg_test.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/unit/rabitqlib/index/qg_test.cpp b/tests/unit/rabitqlib/index/qg_test.cpp index 3272c9d4..59e5e746 100644 --- a/tests/unit/rabitqlib/index/qg_test.cpp +++ b/tests/unit/rabitqlib/index/qg_test.cpp @@ -377,9 +377,11 @@ TEST(BatchQueryTest, PreparedSumPreservesLookupTableAndMetric) { } TEST(QuantizedGraphSearchTest, ScratchSearchMatchesRegularSearch) { - constexpr size_t kNumPoints = 257; for (const size_t kDim : {64U, 65U, 1025U, 4097U}) { SCOPED_TRACE(kDim); + // Cover IDs beyond 255 at small dimensions; wide vectors need only + // a non-complete graph with the same degree and search window. + const size_t kNumPoints = kDim <= 65 ? 257 : 65; constexpr size_t kDegree = 32; constexpr size_t kEfSearch = 1; const size_t kNumQueries = kDim == 64 ? 256 : 16; @@ -395,7 +397,9 @@ TEST(QuantizedGraphSearchTest, ScratchSearchMatchesRegularSearch) { QuantizedGraph graph( kNumPoints, kDim, kDegree, metric, RotatorType::FhtKacRotator, bits, 42 ); - QGBuilder builder(graph, kDegree, data.data(), 1, 42); + // Graph construction is setup here; cache vectors to avoid repeated + // reconstruction. CachedResetMatchesFreshConstruction checks parity. + QGBuilder builder(graph, kDegree, data.data(), 1, 42, true); graph.set_ef(kEfSearch); std::vector rotated_query(graph.padded_dim()); @@ -512,7 +516,7 @@ TEST(QuantizedGraphSearchTest, SingleResultMatchesTopKWithTiesAndHints) { QuantizedGraph graph( kCount, dim, 32, metric, RotatorType::FhtKacRotator, bits, 42 ); - QGBuilder builder(graph, 64, data.data(), 1, 42); + QGBuilder builder(graph, 64, data.data(), 1, 42, true); builder.build(); graph.set_ef(kCount); std::vector rotated(graph.padded_dim()); From 1cf1c38d38d24f55c9c01f254a92886a89ad0975 Mon Sep 17 00:00:00 2001 From: gouyt13clear Date: Tue, 29 Sep 2026 14:55:24 +0800 Subject: [PATCH 2/3] Reduce CI duplication and strengthen clustering and SIMD coverage Keep full portable C++ and repaired-wheel suites while using smoke tests for native C++ and source Python installs. Isolate FAISS comparisons in one Linux job and trim repeated large-dimension Python cases. Run C++ example integration tests in the existing Ubuntu build and fail on missing binaries. Update path routing and exclude these tests from wheel jobs. Exercise both clustering APIs through the installed CMake package and add maximum-dimension RaBitQKMeans training coverage. Reuse the portable binary under pinned, checksum-verified Intel SDE Haswell and Ice Lake models. Require the expected CPU capabilities so AVX2 and AVX-512 dispatch coverage cannot silently disappear. --- .github/scripts/ci_changes.py | 4 + .github/scripts/test_ci_changes.py | 7 ++ .github/workflows/python.yml | 44 ++++++++- .github/workflows/test.yaml | 42 ++++++++- pyproject.toml | 4 +- tests/README.md | 29 +++++- tests/consumer/main.cpp | 89 ++++++++++++++++++- tests/python/test_cpp_clustering_examples.py | 16 +++- tests/python/test_large_dimensions.py | 3 +- tests/python/test_qgkmeans.py | 4 +- .../clustering/rabitqkmeans_test.cpp | 60 +++++++++++++ .../rabitqlib/utils/cpu_features_test.cpp | 25 ++++++ 12 files changed, 310 insertions(+), 17 deletions(-) diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 1393a5d3..c39bca10 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -72,8 +72,12 @@ def classify(paths): selected.add("cpp") if path.endswith(CPP_SUFFIXES): selected.add("cpp_format") + elif path == "tests/python/test_cpp_clustering_examples.py": + selected.update(("cpp", "python_quality")) elif path.startswith("tests/python/"): selected.update(("python_build", "wheels")) + if path == "tests/python/conftest.py": + selected.add("cpp") if path.endswith(".py"): selected.add("python_quality") elif path.startswith("sample/python/"): diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 1dc9b3d8..9d3d15a7 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -44,6 +44,13 @@ def test_language_specific_changes(self): }, "python_bindings/__init__.py": {"python_quality", "python_build", "wheels"}, "python_bindings/licenses/LLVM-OpenMP.txt": {"python_build", "wheels"}, + "tests/python/test_cpp_clustering_examples.py": {"cpp", "python_quality"}, + "tests/python/conftest.py": { + "cpp", + "python_quality", + "python_build", + "wheels", + }, "tests/python/test_ivf.py": {"python_quality", "python_build", "wheels"}, "tests/python/fixtures/ivf_legacy_4bit.index": { "python_build", diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index e44b92ec..391cc83d 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -67,8 +67,16 @@ jobs: - name: Build and install wheel run: pip install ".[test]" -v - - name: Run pytest - run: pytest --tb=short -q + # Repaired-wheel jobs run the full Python suite. + - name: Smoke-test source installation + run: >- + python -m pytest --tb=short -q + tests/python/test_import.py + tests/python/test_ivf.py::test_save_load_roundtrip + tests/python/test_hnsw.py::test_save_load_roundtrip + tests/python/test_symqg.py::test_save_load_roundtrip + tests/python/test_qgkmeans.py::test_train_populates_faiss_style_outputs + tests/python/test_rabitqkmeans.py::test_flat_parameters_and_outputs python-windows: needs: changes @@ -84,5 +92,33 @@ jobs: cache: pip - name: Build and install package run: python -m pip install ".[test]" -v - - name: Test Python bindings - run: python -m pytest tests/python --tb=short -q + - name: Smoke-test source installation + run: >- + python -m pytest --tb=short -q + tests/python/test_import.py + tests/python/test_ivf.py::test_save_load_roundtrip + tests/python/test_hnsw.py::test_save_load_roundtrip + tests/python/test_symqg.py::test_save_load_roundtrip + tests/python/test_qgkmeans.py::test_train_populates_faiss_style_outputs + tests/python/test_rabitqkmeans.py::test_flat_parameters_and_outputs + + faiss-comparison: + needs: changes + if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.python_build == 'true') }} + name: FAISS comparison script + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + OMP_NUM_THREADS: "2" + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.11" + cache: pip + - name: Install system dependencies + run: sudo apt-get update -qq && sudo apt-get install -y cmake + - name: Install comparison dependencies + run: python -m pip install ".[test]" faiss-cpu + - name: Test comparison script + run: python -m pytest tests/python/test_compare_with_faiss.py --tb=short -q diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a77b368f..1c5bedd4 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -98,14 +98,26 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.11" - name: Install dependencies run: sudo apt-get update -qq && sudo apt-get install -y cmake ninja-build - name: Configure - run: cmake -S . -B build -G Ninja -DRABITQ_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Release + run: cmake -S . -B build -G Ninja -DRABITQ_BUILD_TESTS=ON -DRABITQ_BUILD_SAMPLES=ON -DCMAKE_BUILD_TYPE=Release - name: Build run: cmake --build build - - name: Test - run: ctest --test-dir build --output-on-failure + # The installed-consumer job runs the full portable Release suite. + - name: Test native build smoke coverage + run: >- + ctest --test-dir build --output-on-failure --no-tests=error + -R 'MatrixDispatchTest|FloatDistance|FastScan|QGKMeansTest.TrainsAndReturnsAssignmentsForFinalCentroids|RaBitQKMeansTest.FinalModesKeepFlatLloydAssignmentsAcrossClusterCounts' + - name: Install example test dependencies + run: python -m pip install numpy pytest + - name: Test C++ examples + env: + RABITQ_REQUIRE_CPP_EXAMPLES: "1" + run: python -m pytest tests/python/test_cpp_clustering_examples.py --tb=short -q windows: needs: changes @@ -172,6 +184,30 @@ jobs: cmake --build build-install - name: Test portable Release build run: ctest --test-dir build-install --output-on-failure + - name: Download pinned Intel SDE + run: | + curl --fail --location --retry 3 --max-time 120 \ + https://downloadmirror.intel.com/924984/sde-external-10.13.1-2026-07-28-lin.tar.xz \ + -o "$RUNNER_TEMP/sde.tar.xz" + echo "94e97d623fec54385686e1e7ba65ebc9941748c05ee451423948334892bf2b50 $RUNNER_TEMP/sde.tar.xz" | sha256sum --check + mkdir -p "$RUNNER_TEMP/sde" + tar -xJf "$RUNNER_TEMP/sde.tar.xz" -C "$RUNNER_TEMP/sde" --strip-components=1 + - name: Test AVX2-only and AVX-512 dispatch under emulation + env: + OMP_NUM_THREADS: "2" + run: | + simd_filter='CpuFeatures.*:FloatDistance.*:MatrixDispatchTest.*:FastScan*.*:BatchEstimatorTest.*' + simd_filter+=':FhtDispatchTest.*:FlipSignTest.*:KacsWalkTest.*:ExcodeIp.*:MaskIpX0Q.*' + simd_filter+=':PortableKernels.*:QueryPreparation.*:ScalarQuantize.*:WarmupIpX0Q.*:PackBinary.*' + simd_filter+=':ExplicitSimd/RabitqRescaleBackendTest.*:QGEstimatorTest.*' + simd_filter+=':QGKMeansTest.TrainsAndReturnsAssignmentsForFinalCentroids' + simd_filter+=':RaBitQKMeansTest.FinalModesKeepFlatLloydAssignmentsAcrossClusterCounts' + simd_filter+=':IvfPersistenceTest.ReloadsRawAndQuantizedStorage' + simd_filter+=':HnswSaveTest.SuccessfulSavePreservesSearchAfterLoading' + for cpu_model in hsw icl; do + RABITQ_TEST_CPU="$cpu_model" "$RUNNER_TEMP/sde/sde64" "-$cpu_model" -- \ + build-install/tests/rabitq_tests --gtest_filter="$simd_filter" + done - name: Install RaBitQ-Library run: cmake --install build-install - name: Build downstream consumer diff --git a/pyproject.toml b/pyproject.toml index 9008db1f..d1a73b02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,8 +44,8 @@ cmake.args = [ build = "cp311-manylinux_x86_64 cp312-manylinux_x86_64 cp313-manylinux_x86_64 cp314-manylinux_x86_64 cp311-manylinux_aarch64 cp312-manylinux_aarch64 cp313-manylinux_aarch64 cp314-manylinux_aarch64 cp311-win_amd64 cp312-win_amd64 cp313-win_amd64 cp314-win_amd64 cp311-macosx_arm64 cp312-macosx_arm64 cp313-macosx_arm64 cp314-macosx_arm64" build-verbosity = 1 environment = { CMAKE_ARGS = "-DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF" } -test-requires = ["pytest", "faiss-cpu"] -test-command = "pytest {project}/tests/python --tb=short -q" +test-requires = ["pytest"] +test-command = "pytest {project}/tests/python --ignore={project}/tests/python/test_compare_with_faiss.py --ignore={project}/tests/python/test_cpp_clustering_examples.py --tb=short -q" [tool.cibuildwheel.linux] manylinux-aarch64-image = "manylinux_2_28" diff --git a/tests/README.md b/tests/README.md index e5fa9b6b..ab6010a0 100644 --- a/tests/README.md +++ b/tests/README.md @@ -98,6 +98,25 @@ AVX-512 tests skip when the CPU or OS lacks the required features. Tests using `/dev/full` skip on Windows; native POSIX path tests compile only on their applicable platforms. Passing on an AVX2 machine does not verify AVX-512 execution. +## CI coverage + +The installed-consumer job runs the full portable Linux Release suite. The Ubuntu +native build runs SIMD and clustering smoke tests; Windows, ARM64, and ASan/UBSan +jobs retain their full C++ suites. + +The portable Linux job also reuses its test binary under pinned +[Intel SDE](https://www.intel.com/content/www/us/en/download/684897/intel-software-development-emulator.html) +CPU models: Haswell (`-hsw`, AVX2 only) and Ice Lake (`-icl`, AVX-512 including +VPOPCNTDQ). Kernel reference tests and small clustering/index tests exercise real +runtime dispatch under emulation. `RABITQ_TEST_CPU` checks the emulated features +and prints them; an incorrect CPU model fails instead of silently losing coverage. +These are correctness checks, not hardware performance measurements. + +Source-install Python jobs check imports, index persistence, and both clustering +APIs. Repaired-wheel jobs run the full core Python suite. Large-dimension Python +cases check representative binding/persistence paths; C++ retains the exhaustive +clustering dimension/storage matrix. + ## Python tests Use an activated [project environment](../CONTRIBUTING.md#python-environment). @@ -117,16 +136,20 @@ The [Python example tests](python/test_examples.py) cover RaBitQKMeans and QGKMeans clustering, L2/IP indexing, saved cluster files, and queries without importing FAISS. The [FAISS comparison tests](python/test_compare_with_faiss.py) cover both clustering methods and skip FAISS-dependent cases when `faiss-cpu` -is absent; wheel CI includes it as a test dependency. +is absent. CI runs them in a dedicated Linux job; wheel tests do not install FAISS. The [C++ clustering example tests](python/test_cpp_clustering_examples.py) check saved vectors, exact labels, and compatibility with the C++ index builders. -Build the corresponding examples first; cases skip when their executables are absent. +The Ubuntu C++ job runs them against its sample binaries, with +`RABITQ_REQUIRE_CPP_EXAMPLES=1` so missing executables fail. Wheel jobs exclude +these tests. Locally, build the examples first; missing executables otherwise skip. ## Installed CMake package test After building the library, verify that another project can consume its installed -headers and library. Use an absolute path for ``: +headers and library. The consumer trains QGKMeans and RaBitQKMeans and checks +final assignments, distances, and objective against a scalar L2 reference. +Use an absolute path for ``: ```text cmake --install build --config Release --prefix "" diff --git a/tests/consumer/main.cpp b/tests/consumer/main.cpp index 76851cca..c4a68e5b 100644 --- a/tests/consumer/main.cpp +++ b/tests/consumer/main.cpp @@ -1,6 +1,93 @@ +#include +#include +#include +#include +#include +#include + +#include "rabitqlib/clustering/qgkmeans.hpp" +#include "rabitqlib/clustering/rabitqkmeans.hpp" #include "rabitqlib/utils/cpu_features.hpp" +namespace { +constexpr size_t kDimension = 65; +constexpr size_t kPoints = 67; + +// Use public results and an independent scalar reference. Checks must also run +// in Release builds, where assert() would be disabled. +template +bool check_clustering(Model& model, const std::vector& data) { + model.train(kPoints, data.data()); + if (model.centroids.size() != model.k * kDimension || + model.assignments.size() != kPoints || model.distances.size() != kPoints || + model.iteration_stats.empty()) { + return false; + } + for (float value : model.centroids) { + if (!std::isfinite(value)) { + return false; + } + } + double objective = 0; + for (size_t point = 0; point < kPoints; ++point) { + if (model.assignments[point] >= model.k) { + return false; + } + double nearest = std::numeric_limits::infinity(); + double selected = 0; + for (size_t cluster = 0; cluster < model.k; ++cluster) { + double distance = 0; + for (size_t dim = 0; dim < kDimension; ++dim) { + const double delta = static_cast(data[point * kDimension + dim]) - + model.centroids[cluster * kDimension + dim]; + distance += delta * delta; + } + nearest = std::min(nearest, distance); + if (cluster == model.assignments[point]) { + selected = distance; + } + } + const double tolerance = 1e-4 * std::max(1.0, nearest); + if (!(std::abs(selected - nearest) <= tolerance) || + !(std::abs(model.distances[point] - selected) <= tolerance)) { + return false; + } + objective += selected; + } + return std::abs(model.final_obj - objective) <= 1e-4 * std::max(1.0, objective); +} +} // namespace + int main() { const auto& features = rabitqlib::cpu::features(); - return features.avx512vpopcntdq && !features.avx512f ? 1 : 0; + if (features.avx512vpopcntdq && !features.avx512f) { + return 1; + } + std::vector data(kPoints * kDimension); + for (size_t i = 0; i < data.size(); ++i) { + data[i] = std::sin(static_cast(i) * 0.17F); + } + + rabitqlib::qgkmeans::QGKMeansParameters graph_parameters; + graph_parameters.niter = 2; + graph_parameters.num_threads = 2; + graph_parameters.ef_build = 33; + graph_parameters.ef_search = 33; + graph_parameters.final_assignment = rabitqlib::clustering::FinalAssignmentMode::Exact; + rabitqlib::qgkmeans::QGKMeans graph(kDimension, 33, graph_parameters); + if (!check_clustering(graph, data)) { + std::cerr << "Installed QGKMeans returned invalid clustering results\n"; + return 1; + } + + rabitqlib::rabitqkmeans::RaBitQKMeansParameters flat_parameters; + flat_parameters.niter = 2; + flat_parameters.num_threads = 2; + flat_parameters.final_assignment = rabitqlib::clustering::FinalAssignmentMode::Exact; + rabitqlib::rabitqkmeans::RaBitQKMeans flat(kDimension, 4, flat_parameters); + if (!check_clustering(flat, data)) { + std::cerr << "Installed RaBitQKMeans returned invalid clustering results\n"; + return 1; + } + return 0; } diff --git a/tests/python/test_cpp_clustering_examples.py b/tests/python/test_cpp_clustering_examples.py index 22dee2c5..e331d583 100644 --- a/tests/python/test_cpp_clustering_examples.py +++ b/tests/python/test_cpp_clustering_examples.py @@ -13,7 +13,10 @@ def run_cpp(name, *args): executable = BIN / (name + (".exe" if os.name == "nt" else "")) if not executable.is_file(): - pytest.skip(f"Build the C++ example target {name} first") + message = f"Build the C++ example target {name} first" + if os.environ.get("RABITQ_REQUIRE_CPP_EXAMPLES") == "1": + pytest.fail(message) + pytest.skip(message) return subprocess.run( [str(executable), *map(str, args)], env=dict(os.environ, OMP_THREAD_LIMIT="2", OMP_NUM_THREADS="2"), @@ -119,3 +122,14 @@ def test_cpp_clustering_usage_and_errors(tmp_path, method): result = run_cpp(method, *args) assert result.returncode == 1 assert "File does not exist:" in result.stderr + + +@pytest.mark.parametrize("required", [False, True]) +def test_missing_cpp_example_fails_when_required(tmp_path, monkeypatch, required): + monkeypatch.setitem(run_cpp.__globals__, "BIN", tmp_path) + monkeypatch.setenv("RABITQ_REQUIRE_CPP_EXAMPLES", "1" if required else "0") + expected = pytest.fail.Exception if required else pytest.skip.Exception + with pytest.raises( + expected, match=r"Build the C\+\+ example target qgkmeans first" + ): + run_cpp("qgkmeans") diff --git a/tests/python/test_large_dimensions.py b/tests/python/test_large_dimensions.py index 162c7cba..d03b56ee 100644 --- a/tests/python/test_large_dimensions.py +++ b/tests/python/test_large_dimensions.py @@ -5,7 +5,8 @@ from rabitqlib import HnswIndex, IvfIndex, SymqgIndex -@pytest.mark.parametrize("dim", [4096, 16384, 16385, 65536]) +# Keep a padded dimension and the maximum across every index and metric. +@pytest.mark.parametrize("dim", [16385, 65536]) @pytest.mark.parametrize("metric", ["l2", "ip"]) @pytest.mark.parametrize("kind", ["ivf", "hnsw", "qg"]) def test_large_dimension_round_trip(tmp_path, dim, metric, kind): diff --git a/tests/python/test_qgkmeans.py b/tests/python/test_qgkmeans.py index ff855770..d29719fa 100644 --- a/tests/python/test_qgkmeans.py +++ b/tests/python/test_qgkmeans.py @@ -411,8 +411,8 @@ def test_training_size_warning_is_opt_in(capfd, verbose): assert ("please provide at least" in captured.err) == verbose -@pytest.mark.parametrize("dim", [4096, 4097, 65535, 65536]) -@pytest.mark.parametrize("bits", [0, 4, 8]) +# C++ covers the full dimension/storage matrix; check binding outputs here. +@pytest.mark.parametrize("dim,bits", [(4097, 0), (65535, 4), (65536, 8)]) def test_large_dimensions(dim, bits): x = np.zeros((33, dim), dtype=np.float32) x[np.arange(33), np.arange(33)] = 1 diff --git a/tests/unit/rabitqlib/clustering/rabitqkmeans_test.cpp b/tests/unit/rabitqlib/clustering/rabitqkmeans_test.cpp index 5a967e68..79b972a5 100644 --- a/tests/unit/rabitqlib/clustering/rabitqkmeans_test.cpp +++ b/tests/unit/rabitqlib/clustering/rabitqkmeans_test.cpp @@ -411,6 +411,66 @@ TEST(RaBitQKMeansTest, FinalModesKeepFlatLloydAssignmentsAcrossClusterCounts) { } } +TEST(RaBitQKMeansTest, TrainsAtMaximumDimension) { + // A partial FastScan batch keeps the maximum-width training check small. + constexpr size_t kDim = 65536, kPoints = 5, kClusters = 2; + std::vector data(kPoints * kDim); + for (size_t point = 0; point < kPoints; ++point) { + // Exercise the full extent, including the last coordinate, without + // making the distance check depend on long float accumulation chains. + for (size_t dim = 1023; dim < kDim; dim += 1024) { + data[point * kDim + dim] = + static_cast(static_cast((dim + point * 7) % 19) - 9) / 256.0F; + } + } + const auto original = data; + for (const auto mode : {FinalAssignmentMode::Approximate, FinalAssignmentMode::Exact}) { + SCOPED_TRACE(static_cast(mode)); + RaBitQKMeansParameters parameters; + parameters.niter = 2; + parameters.num_threads = 2; + parameters.final_assignment = mode; + RaBitQKMeans model(kDim, kClusters, parameters); + model.train(kPoints, data.data()); + ASSERT_EQ(model.iteration_stats.size(), 2U); + ASSERT_EQ(model.centroids.size(), kClusters * kDim); + ASSERT_EQ(model.assignments.size(), kPoints); + ASSERT_EQ(model.distances.size(), kPoints); + EXPECT_EQ(data, original); + EXPECT_TRUE(std::all_of( + model.centroids.begin(), + model.centroids.end(), + [](float value) { return std::isfinite(value); } + )); + double objective = 0; + for (size_t point = 0; point < kPoints; ++point) { + ASSERT_LT(model.assignments[point], kClusters); + const double selected = scalar_centroid_distance( + data.data() + point * kDim, + model.centroids.data() + model.assignments[point] * kDim, + kDim, + false + ); + EXPECT_NEAR(model.distances[point], selected, 2e-6 * std::max(1.0, selected)); + objective += selected; + if (mode == FinalAssignmentMode::Exact) { + for (size_t cluster = 0; cluster < kClusters; ++cluster) { + EXPECT_LE( + selected, + scalar_centroid_distance( + data.data() + point * kDim, + model.centroids.data() + cluster * kDim, + kDim, + false + ) + ); + } + } + } + EXPECT_NEAR(model.final_obj, objective, 2e-6 * std::max(1.0, objective)); + } +} + TEST(RaBitQKMeansTest, PointCodesPreserveBorrowedCentroidsDuringRetraining) { constexpr size_t kDim = 65, kPoints = 129; std::mt19937 rng(945); diff --git a/tests/unit/rabitqlib/utils/cpu_features_test.cpp b/tests/unit/rabitqlib/utils/cpu_features_test.cpp index cc755699..1beeb51a 100644 --- a/tests/unit/rabitqlib/utils/cpu_features_test.cpp +++ b/tests/unit/rabitqlib/utils/cpu_features_test.cpp @@ -3,6 +3,9 @@ #include #include +#include +#include +#include namespace { @@ -30,6 +33,28 @@ TEST(CpuFeatures, ReturnsStableResult) { ); } +// The emulated CI runs must fail instead of silently skipping optional backends. +TEST(CpuFeatures, MatchesRequiredEmulatedCpu) { + const bool avx2 = rabitqlib::cpu::has_avx2(); + const bool avx512 = rabitqlib::cpu::has_avx512_core(); + const bool popcnt = rabitqlib::cpu::has_avx512_popcnt(); + const bool neon = rabitqlib::cpu::has_neon(); + std::cout << "SIMD capabilities: AVX2/FMA=" << avx2 << " AVX512=" << avx512 + << " AVX512_VPOPCNTDQ=" << popcnt << " NEON=" << neon << '\n'; + const char* required = std::getenv("RABITQ_TEST_CPU"); + if (required == nullptr) { + return; + } + SCOPED_TRACE(required); + ASSERT_TRUE(std::strcmp(required, "hsw") == 0 || std::strcmp(required, "icl") == 0) + << "RABITQ_TEST_CPU must be hsw or icl"; + EXPECT_TRUE(avx2); + EXPECT_FALSE(neon); + const bool require_avx512 = std::strcmp(required, "icl") == 0; + EXPECT_EQ(avx512, require_avx512); + EXPECT_EQ(popcnt, require_avx512); +} + TEST(CpuFeatures, RequiresOsManagedAvxState) { constexpr uint64_t kAvxState = 0x6; const auto hardware = all_hardware_features(); From df4d324c56aff573738f94b8a5fc26b5d70d8e4b Mon Sep 17 00:00:00 2001 From: gouyt13clear Date: Tue, 29 Sep 2026 15:21:05 +0800 Subject: [PATCH 3/3] Optimize sanitizer builds while retaining Debug checks Compile the sanitizer CI configuration with -O1 and debug symbols, keeping assertions, ASan/UBSan, leak detection and frame pointers enabled. Disable sibling-call optimization for useful diagnostic stacks. Run the same full test suite without reducing fixtures or coverage. --- .github/workflows/test.yaml | 2 ++ tests/README.md | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1c5bedd4..6d97338d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -147,6 +147,7 @@ jobs: - uses: actions/checkout@v7 - name: Install dependencies run: sudo apt-get update -qq && sudo apt-get install -y cmake ninja-build + # Keep assertions/debug symbols while avoiding unoptimized sanitizer loops. - name: Configure run: >- cmake -S . -B build-sanitizers -G Ninja @@ -155,6 +156,7 @@ jobs: -DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF -DRABITQ_ENABLE_SANITIZERS=ON -DCMAKE_BUILD_TYPE=Debug + -DCMAKE_CXX_FLAGS_DEBUG="-O1 -g -fno-optimize-sibling-calls" - name: Build run: cmake --build build-sanitizers - name: Test diff --git a/tests/README.md b/tests/README.md index ab6010a0..d62fe882 100644 --- a/tests/README.md +++ b/tests/README.md @@ -102,7 +102,9 @@ applicable platforms. Passing on an AVX2 machine does not verify AVX-512 executi The installed-consumer job runs the full portable Linux Release suite. The Ubuntu native build runs SIMD and clustering smoke tests; Windows, ARM64, and ASan/UBSan -jobs retain their full C++ suites. +jobs retain their full C++ suites. The sanitizer job uses Debug with +`-O1 -g -fno-optimize-sibling-calls`, retaining assertions, frame pointers, leak +checking, and both ASan/UBSan while avoiding unoptimized clustering loops. The portable Linux job also reuses its test binary under pinned [Intel SDE](https://www.intel.com/content/www/us/en/download/684897/intel-software-development-emulator.html)