From 2bee6e4bff634122cb6f9b563ab9825f2583c597 Mon Sep 17 00:00:00 2001 From: Zefeng Yin Date: Fri, 28 Aug 2026 14:29:18 +0800 Subject: [PATCH 1/5] fix(diskann): prevent aligned reader handle copies --- src/core/algorithm/diskann/diskann_file_reader.h | 6 ++++++ tests/core/algorithm/diskann/diskann_searcher_test.cc | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/src/core/algorithm/diskann/diskann_file_reader.h b/src/core/algorithm/diskann/diskann_file_reader.h index 17a37d782..2d69ffbd2 100644 --- a/src/core/algorithm/diskann/diskann_file_reader.h +++ b/src/core/algorithm/diskann/diskann_file_reader.h @@ -156,6 +156,8 @@ class LinuxAlignedFileReader : public AlignedFileReader { public: LinuxAlignedFileReader(); LinuxAlignedFileReader(int file_desc); + LinuxAlignedFileReader(const LinuxAlignedFileReader &) = delete; + LinuxAlignedFileReader &operator=(const LinuxAlignedFileReader &) = delete; ~LinuxAlignedFileReader() override; public: @@ -188,6 +190,10 @@ class WindowsAlignedFileReader : public AlignedFileReader { void reset_io_ctx(IOContext &ctx); public: + WindowsAlignedFileReader() = default; + WindowsAlignedFileReader(const WindowsAlignedFileReader &) = delete; + WindowsAlignedFileReader &operator=(const WindowsAlignedFileReader &) = + delete; ~WindowsAlignedFileReader() override; void open(const std::string &fname) override; diff --git a/tests/core/algorithm/diskann/diskann_searcher_test.cc b/tests/core/algorithm/diskann/diskann_searcher_test.cc index daa1f561d..ce0dae243 100644 --- a/tests/core/algorithm/diskann/diskann_searcher_test.cc +++ b/tests/core/algorithm/diskann/diskann_searcher_test.cc @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -85,6 +86,9 @@ using namespace zvec::core; using namespace zvec::ailego; using namespace std; +static_assert(!std::is_copy_constructible_v); +static_assert(!std::is_copy_assignable_v); + constexpr size_t static dim = 64; namespace { From 1fb3f05ebb7f070cdeb85073b861c722525d24e0 Mon Sep 17 00:00:00 2001 From: Zefeng Yin Date: Fri, 28 Aug 2026 15:25:55 +0800 Subject: [PATCH 2/5] feat(mobile): enable DiskANN on Android and iOS --- .github/workflows/04-android-build.yml | 316 ++++++-- .github/workflows/06-ios-build.yml | 186 ++++- CMakeLists.txt | 22 +- cmake/bazel.cmake | 7 +- examples/c++/CMakeLists.txt | 164 +++-- examples/c/diskann_example.c | 3 +- examples/c/optimized_example.c | 4 +- scripts/build_android.sh | 33 + src/CMakeLists.txt | 83 +++ src/core/algorithm/CMakeLists.txt | 2 +- .../algorithm/diskann/diskann_file_reader.cc | 24 +- .../algorithm/diskann/diskann_file_reader.h | 10 +- .../algorithm/diskann/diskann_pq_trainer.cc | 21 +- src/db/index/common/schema.cc | 9 +- tests/c/CMakeLists.txt | 2 +- tests/c/c_api_test.c | 10 +- tests/c/utils.c | 6 +- tests/core/algorithm/diskann/CMakeLists.txt | 26 +- .../diskann/diskann_mobile_compat_test.cc | 416 +++++++++++ tests/db/CMakeLists.txt | 11 + tests/db/collection_test.cc | 4 +- tests/db/diskann_mobile_collection_test.cc | 688 ++++++++++++++++++ 22 files changed, 1845 insertions(+), 202 deletions(-) create mode 100644 tests/core/algorithm/diskann/diskann_mobile_compat_test.cc create mode 100644 tests/db/diskann_mobile_collection_test.cc diff --git a/.github/workflows/04-android-build.yml b/.github/workflows/04-android-build.yml index 6b2abd5fb..476f0febf 100644 --- a/.github/workflows/04-android-build.yml +++ b/.github/workflows/04-android-build.yml @@ -23,7 +23,9 @@ jobs: strategy: fail-fast: false matrix: - abi: [x86_64] + # x86_64 runs the emulator suite; arm64-v8a provides compile/link + # coverage for the ABI used by physical Android devices. + abi: [x86_64, arm64-v8a] api: ${{ github.event.inputs.api && fromJSON(format('["{0}"]', github.event.inputs.api)) || fromJSON('["34"]') }} steps: # ── Environment setup ────────────────────────────────────────────── @@ -55,16 +57,22 @@ jobs: uses: android-actions/setup-android@v4 - name: Enable KVM + if: matrix.abi == 'x86_64' run: sudo chmod 666 /dev/kvm || true - - name: Install NDK, emulator and system image + - name: Install NDK and Android platform shell: bash run: | sdkmanager --install \ "ndk;$NDK_VERSION" \ "platform-tools" \ - "platforms;android-${{ matrix.api }}" \ - "emulator" + "platforms;android-${{ matrix.api }}" + + - name: Install emulator and x86_64 system image + if: matrix.abi == 'x86_64' + shell: bash + run: | + sdkmanager --install "emulator" # Install x86_64 system image (try variants in order of availability) sdkmanager --install "system-images;android-${{ matrix.api }};google_apis;x86_64" 2>/dev/null || \ @@ -92,8 +100,12 @@ jobs: -DANDROID_NATIVE_API_LEVEL=${{ matrix.api }} \ -DANDROID_STL=c++_static \ -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_ZVEC_SHARED=OFF \ + -DBUILD_ZVEC_AILEGO_SHARED=OFF \ + -DBUILD_ZVEC_CORE_SHARED=OFF \ -DBUILD_PYTHON_BINDINGS=OFF \ -DBUILD_TOOLS=OFF \ + -DBUILD_CPP_EXAMPLES=ON \ -DENABLE_NATIVE=OFF \ -DAUTO_DETECT_ARCH=OFF \ -DENABLE_WERROR=ON \ @@ -101,8 +113,19 @@ jobs: -DCMAKE_C_COMPILER_LAUNCHER=ccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache - echo "Building all targets..." + if [ "${{ matrix.abi }}" = "arm64-v8a" ]; then + echo "Building focused DiskAnn tests and public C++ examples for arm64-v8a..." + cmake --build "$BUILD_DIR" \ + --target diskann_mobile_compat_test diskann_mobile_collection_test zvec_cpp_examples \ + --parallel + exit 0 + fi + + echo "Building all x86_64 targets..." cmake --build "$BUILD_DIR" --parallel + cmake --build "$BUILD_DIR" \ + --target diskann_mobile_compat_test diskann_mobile_collection_test \ + --parallel # Discover test targets from ctest metadata echo "Discovering test targets..." @@ -121,11 +144,56 @@ jobs: done < <(ninja -C "$BUILD_DIR" -t targets all 2>/dev/null || true) fi + if [ ${#TEST_NAMES[@]} -eq 0 ]; then + echo "ERROR: No Android test targets were discovered" + exit 1 + fi + + # All test cases in this legacy target are disabled with #if 0. + # Exclude it instead of treating a zero-test binary as a passing suite. + FILTERED_TEST_NAMES=() + for name in "${TEST_NAMES[@]}"; do + if [ "$name" = "cosine_distance_matrix_int8_test" ]; then + echo "Skipping $name: it contains no enabled GTest cases" + continue + fi + FILTERED_TEST_NAMES+=("$name") + done + TEST_NAMES=("${FILTERED_TEST_NAMES[@]}") + + if [ ${#TEST_NAMES[@]} -eq 0 ]; then + echo "ERROR: No non-empty Android test targets were discovered" + exit 1 + fi + + for required_test in diskann_mobile_compat_test diskann_mobile_collection_test; do + found=0 + for name in "${TEST_NAMES[@]}"; do + if [ "$name" = "$required_test" ]; then + found=1 + break + fi + done + if [ "$found" -ne 1 ]; then + echo "ERROR: Required Android DiskAnn test target was not discovered: $required_test" + exit 1 + fi + done + echo "Building ${#TEST_NAMES[@]} test executables..." ninja -C "$BUILD_DIR" -j$(nproc) "${TEST_NAMES[@]}" - # ── Step 2: start emulator ───────────────────────────────────────── - - name: 'Step 2: Start Android emulator' + for required_test in diskann_mobile_compat_test diskann_mobile_collection_test; do + required_binary="$BUILD_DIR/bin/$required_test" + if [ ! -x "$required_binary" ]; then + echo "ERROR: Missing required Android DiskAnn test binary: $required_binary" + exit 1 + fi + done + + # ── Step 3: start emulator ───────────────────────────────────────── + - name: 'Step 3: Start Android emulator' + if: matrix.abi == 'x86_64' shell: bash run: | AVD_NAME="zvec_test_avd" @@ -218,8 +286,9 @@ jobs: echo "Device ABI: $(adb shell getprop ro.product.cpu.abi | tr -d '\r')" echo "ABI list : $(adb shell getprop ro.product.cpu.abilist | tr -d '\r')" - # ── Step 3: run unit tests on emulator ───────────────────────────── - - name: 'Step 3: Run unit tests on emulator' + # ── Step 4: run unit tests on emulator ───────────────────────────── + - name: 'Step 4: Run unit tests on emulator' + if: matrix.abi == 'x86_64' shell: bash env: BUILD_DIR: build_android_${{ matrix.abi }} @@ -262,6 +331,34 @@ jobs: done < <(ninja -C "$BUILD_DIR" -t targets all 2>/dev/null || true) fi + if [ ${#TEST_NAMES[@]} -eq 0 ]; then + echo "ERROR: No Android test targets were discovered" + exit 1 + fi + + FILTERED_TEST_NAMES=() + for name in "${TEST_NAMES[@]}"; do + if [ "$name" = "cosine_distance_matrix_int8_test" ]; then + continue + fi + FILTERED_TEST_NAMES+=("$name") + done + TEST_NAMES=("${FILTERED_TEST_NAMES[@]}") + + for required_test in diskann_mobile_compat_test diskann_mobile_collection_test; do + found=0 + for name in "${TEST_NAMES[@]}"; do + if [ "$name" = "$required_test" ]; then + found=1 + break + fi + done + if [ "$found" -ne 1 ]; then + echo "ERROR: Required Android DiskAnn test target was not discovered: $required_test" + exit 1 + fi + done + # Collect test binaries TEST_BINS=() for name in "${TEST_NAMES[@]}"; do @@ -269,15 +366,31 @@ jobs: if [ -n "$bin_path" ]; then TEST_BINS+=("$bin_path") else - echo "WARNING: binary not found for '$name'" + echo "ERROR: binary not found for discovered test target '$name'" + exit 1 + fi + done + + for required_test in diskann_mobile_compat_test diskann_mobile_collection_test; do + required_binary="$BUILD_DIR/bin/$required_test" + if [ ! -x "$required_binary" ]; then + echo "ERROR: Missing required Android DiskAnn test binary: $required_binary" + exit 1 fi done TOTAL=${#TEST_BINS[@]} + if [ "$TOTAL" -eq 0 ]; then + echo "ERROR: No Android test binaries were collected" + exit 1 + fi + PASSED=0 FAILED=0 FAILED_NAMES=() IDX=0 + DISKANN_COMPAT_PASSED=0 + DISKANN_COLLECTION_PASSED=0 echo "Running $TOTAL unit tests on emulator..." @@ -293,45 +406,90 @@ jobs: echo " [$IDX/$TOTAL] $test_name" echo "────────────────────────────────────────" - set +e # Create isolated working directory adb shell "mkdir -p $WORK_DIR" 2>/dev/null # Copy helper binaries into working directory so crash_recovery tests # (which fork+exec data_generator / collection_optimizer) can find them - adb shell "for h in $DEVICE_TEST_DIR/data_generator $DEVICE_TEST_DIR/collection_optimizer; do [ -f \$h ] && cp \$h $WORK_DIR/; done" 2>/dev/null + adb shell "for h in $DEVICE_TEST_DIR/data_generator $DEVICE_TEST_DIR/collection_optimizer; do [ -f \$h ] && cp \$h $WORK_DIR/; done" 2>/dev/null || true # Push test binary adb push "$test_bin" "$device_path" > /dev/null 2>&1 adb shell "chmod 755 $device_path" 2>/dev/null - # Run test from its own working directory with LD_LIBRARY_PATH - OUTPUT=$(adb shell "cd $WORK_DIR && LD_LIBRARY_PATH=$DEVICE_LIB_DIR $device_path 2>&1; echo EXIT_CODE=\$?" 2>&1) + # Run test from its own working directory with a hard timeout. + # GTEST_COLOR=no keeps the required summary machine-readable. + set +e + OUTPUT=$(timeout --signal=TERM --kill-after=10s 600s \ + adb shell "cd $WORK_DIR && GTEST_COLOR=no LD_LIBRARY_PATH=$DEVICE_LIB_DIR $device_path 2>&1; echo EXIT_CODE=\$?" 2>&1) + ADB_EXIT=$? + set -e # Extract exit code from the output - EXIT_CODE=$(echo "$OUTPUT" | grep -o 'EXIT_CODE=[0-9]*' | tail -1 | cut -d= -f2) - set -e + EXIT_CODE=$(printf '%s\n' "$OUTPUT" | grep -o 'EXIT_CODE=[0-9]*' | tail -1 | cut -d= -f2 || true) # Print test output (without the EXIT_CODE marker) echo "$OUTPUT" | grep -v 'EXIT_CODE=' | sed 's/^/ /' || true - if [ "$EXIT_CODE" = "0" ]; then + EXPECTED_COUNT="" + case "$test_name" in + diskann_mobile_compat_test) + EXPECTED_COUNT=7 + ;; + diskann_mobile_collection_test) + EXPECTED_COUNT=4 + ;; + esac + + TEST_PASSED=1 + if [ "$ADB_EXIT" -eq 124 ] || [ "$ADB_EXIT" -eq 137 ]; then + echo " >>> FAILED (timed out after 600 seconds)" + adb shell "pkill -f $device_path" 2>/dev/null || true + TEST_PASSED=0 + elif [ "$ADB_EXIT" -ne 0 ]; then + echo " >>> FAILED (adb exit=$ADB_EXIT)" + TEST_PASSED=0 + elif [ -z "$EXIT_CODE" ]; then + echo " >>> FAILED (missing device exit code)" + TEST_PASSED=0 + elif [ "$EXIT_CODE" -ne 0 ]; then + echo " >>> FAILED (exit=$EXIT_CODE)" + TEST_PASSED=0 + elif grep -Eq '^\[ FAILED \]' <<< "$OUTPUT"; then + echo " >>> FAILED (GTest reported failures)" + TEST_PASSED=0 + elif [ -n "$EXPECTED_COUNT" ] && \ + ! grep -Eq "^\\[ PASSED \\][[:space:]]+${EXPECTED_COUNT} tests?\\.[[:space:]]*$" <<< "$OUTPUT"; then + echo " >>> FAILED (required ${EXPECTED_COUNT}/${EXPECTED_COUNT} summary not found)" + TEST_PASSED=0 + elif [ -n "$EXPECTED_COUNT" ] && \ + ! grep -Eq "^\\[==========\\][[:space:]]+${EXPECTED_COUNT} tests?[[:space:]]+from[[:space:]]+[1-9][0-9]* test (suites?|cases?)[[:space:]]+ran\\." <<< "$OUTPUT"; then + echo " >>> FAILED (expected exactly ${EXPECTED_COUNT} executed tests)" + TEST_PASSED=0 + elif [ -n "$EXPECTED_COUNT" ]; then + : # The required DiskAnn suite ran exactly N/N tests successfully. + elif grep -Eq '^\[==========\][[:space:]]+[1-9][0-9]* tests?[[:space:]]+from[[:space:]]+[1-9][0-9]* test (suites?|cases?)[[:space:]]+ran\.' <<< "$OUTPUT"; then + : # A non-empty GTest suite completed without failures; skips are valid. + elif [ "$test_name" = "c_api_test" ] && \ + grep -Eq '^Passed: [1-9][0-9]*[[:space:]]*$' <<< "$OUTPUT" && \ + grep -Eq '^Failed: 0[[:space:]]*$' <<< "$OUTPUT"; then + : # c_api_test uses a custom test framework. + else + echo " >>> FAILED (no recognisable non-empty passing test summary)" + TEST_PASSED=0 + fi + + if [ "$TEST_PASSED" -eq 1 ]; then echo " >>> PASSED" PASSED=$((PASSED + 1)) - else - # Detect "crash-on-exit" pattern: all gtest assertions passed but - # process crashed during static destructor teardown (common with c++_static STL) - GTEST_PASSED_LINE=$(echo "$OUTPUT" | grep '\[ PASSED \]' | tail -1 || true) - GTEST_FAILED_LINE=$(echo "$OUTPUT" | grep '\[ FAILED \]' | head -1 || true) - if [ -n "$GTEST_PASSED_LINE" ] && [ -z "$GTEST_FAILED_LINE" ] && \ - { [ "$EXIT_CODE" = "139" ] || [ "$EXIT_CODE" = "134" ] || [ "$EXIT_CODE" = "135" ]; }; then - echo " >>> PASSED (crash-on-exit ignored, exit=$EXIT_CODE)" - PASSED=$((PASSED + 1)) - else - echo " >>> FAILED (exit=$EXIT_CODE)" - FAILED=$((FAILED + 1)) - FAILED_NAMES+=("$test_name") + if [ "$test_name" = "diskann_mobile_compat_test" ]; then + DISKANN_COMPAT_PASSED=1 + elif [ "$test_name" = "diskann_mobile_collection_test" ]; then + DISKANN_COLLECTION_PASSED=1 fi + else + FAILED=$((FAILED + 1)) + FAILED_NAMES+=("$test_name") fi # Clean up binary and working directory to reclaim disk space @@ -354,54 +512,88 @@ jobs: fi echo "============================================================" + if [ "$DISKANN_COMPAT_PASSED" -ne 1 ] || [ "$DISKANN_COLLECTION_PASSED" -ne 1 ]; then + echo "Required DiskAnn tests did not both complete successfully" + exit 1 + fi + if [ "$PASSED" -ne "$TOTAL" ]; then + echo "Only ${PASSED}/${TOTAL} Android tests completed successfully" + exit 1 + fi if [ $FAILED -gt 0 ]; then exit 1 fi echo "All tests passed!" - # ── Step 4: build and run examples ───────────────────────────────── - - name: 'Step 4: Build and run examples' + # ── Step 5: build and run examples ───────────────────────────────── + - name: 'Step 5: Build and run examples' + if: matrix.abi == 'x86_64' shell: bash env: BUILD_DIR: build_android_${{ matrix.abi }} run: | - ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION" - EXAMPLES_BUILD="examples/c++/build-android-examples-${{ matrix.abi }}" + cmake --build "$BUILD_DIR" --target zvec_cpp_examples --parallel + READELF="$ANDROID_HOME/ndk/$NDK_VERSION/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf" - cmake -S examples/c++ -B "$EXAMPLES_BUILD" -G Ninja \ - -DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake" \ - -DANDROID_ABI=${{ matrix.abi }} \ - -DANDROID_PLATFORM=android-${{ matrix.api }} \ - -DANDROID_STL=c++_static \ - -DCMAKE_BUILD_TYPE=Release \ - -DHOST_BUILD_DIR="$BUILD_DIR" \ - -DCMAKE_C_COMPILER_LAUNCHER=ccache \ - -DCMAKE_CXX_COMPILER_LAUNCHER=ccache - cmake --build "$EXAMPLES_BUILD" --parallel + for example in ailego-example core-example external-vector-example db-example; do + example_path="$BUILD_DIR/bin/$example" + if [ ! -f "$example_path" ]; then + echo "Missing example binary: $example_path" + exit 1 + fi - # Reuse the shared-library directory from Step 3; push again in - # case Step 3 was skipped or the directory was cleaned. - DEVICE_LIB_DIR="/data/local/tmp/zvec_tests/lib" - adb shell "mkdir -p $DEVICE_LIB_DIR" 2>/dev/null || true - SO_COUNT=0 - while IFS= read -r so_file; do - adb push "$so_file" "$DEVICE_LIB_DIR/$(basename "$so_file")" > /dev/null 2>&1 - SO_COUNT=$((SO_COUNT + 1)) - done < <(find "$BUILD_DIR/lib" -name "*.so" -type f 2>/dev/null) - echo "Pushed $SO_COUNT shared libraries to $DEVICE_LIB_DIR" - - for example in ailego-example core-example db-example; do - if [ -f "$EXAMPLES_BUILD/$example" ]; then - echo "=== Running $example ===" - adb push "$EXAMPLES_BUILD/$example" "/data/local/tmp/$example" > /dev/null 2>&1 - adb shell "chmod 755 /data/local/tmp/$example && cd /data/local/tmp && LD_LIBRARY_PATH=$DEVICE_LIB_DIR ./$example" - adb shell "rm -f /data/local/tmp/$example" + echo "=== Verifying $example is self-contained ===" + dynamic_section=$("$READELF" --dynamic "$example_path") + echo "$dynamic_section" | grep NEEDED || true + if echo "$dynamic_section" | grep -Eq 'libzvec[^]]*\.so|libc\+\+_shared\.so'; then + echo "$example unexpectedly depends on a C++ shared library" + exit 1 + fi + + echo "=== Running $example ===" + adb push "$example_path" "/data/local/tmp/$example" > /dev/null 2>&1 + adb shell "chmod 755 /data/local/tmp/$example && cd /data/local/tmp && ./$example" + adb shell "rm -f /data/local/tmp/$example" + done + + - name: 'Step 3: Verify arm64-v8a artifacts' + if: matrix.abi == 'arm64-v8a' + shell: bash + env: + BUILD_DIR: build_android_${{ matrix.abi }} + run: | + READELF="$ANDROID_HOME/ndk/$NDK_VERSION/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf" + + for binary in \ + diskann_mobile_compat_test \ + diskann_mobile_collection_test \ + ailego-example \ + core-example \ + external-vector-example \ + db-example; do + binary_path="$BUILD_DIR/bin/$binary" + if [ ! -f "$binary_path" ]; then + echo "Missing arm64-v8a binary: $binary_path" + exit 1 + fi + if ! "$READELF" --file-header "$binary_path" | grep -q 'Machine:.*AArch64'; then + echo "$binary_path is not an AArch64 binary" + exit 1 + fi + done + + for example in ailego-example core-example external-vector-example db-example; do + example_path="$BUILD_DIR/bin/$example" + dynamic_section=$("$READELF" --dynamic "$example_path") + if echo "$dynamic_section" | grep -Eq 'libzvec[^]]*\.so|libc\+\+_shared\.so'; then + echo "$example unexpectedly depends on a C++ shared library" + exit 1 fi done # ── Cleanup ──────────────────────────────────────────────────────── - name: Stop emulator - if: always() + if: matrix.abi == 'x86_64' && always() shell: bash run: | adb emu kill 2>/dev/null || true diff --git a/.github/workflows/06-ios-build.yml b/.github/workflows/06-ios-build.yml index f5b95974b..53dca9a11 100644 --- a/.github/workflows/06-ios-build.yml +++ b/.github/workflows/06-ios-build.yml @@ -10,6 +10,7 @@ permissions: jobs: build-ios: runs-on: macos-15 + timeout-minutes: 120 strategy: fail-fast: false matrix: @@ -50,8 +51,12 @@ jobs: -DCMAKE_OSX_ARCHITECTURES="${{ matrix.arch }}" \ -DCMAKE_OSX_SYSROOT="$SDK_PATH" \ -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_ZVEC_SHARED=OFF \ + -DBUILD_ZVEC_AILEGO_SHARED=OFF \ + -DBUILD_ZVEC_CORE_SHARED=OFF \ -DBUILD_PYTHON_BINDINGS=OFF \ -DBUILD_TOOLS=OFF \ + -DBUILD_CPP_EXAMPLES=ON \ -DENABLE_WERROR=ON \ -DCMAKE_INSTALL_PREFIX="./install" \ -DIOS=ON \ @@ -61,6 +66,36 @@ jobs: cmake --build build_ios_${{ matrix.platform }} --parallel $NPROC + - name: Build public static C++ examples + run: | + NPROC=$(sysctl -n hw.ncpu) + BUILD_DIR=build_ios_${{ matrix.platform }} + cmake --build "$BUILD_DIR" --target zvec_cpp_examples --parallel "$NPROC" + + for example in ailego-example core-example external-vector-example db-example; do + example_binary="$BUILD_DIR/bin/$example.app/$example" + if [ ! -f "$example_binary" ]; then + echo "Missing iOS example binary: $example_binary" + exit 1 + fi + done + + - name: Build required DiskAnn test targets + run: | + NPROC=$(sysctl -n hw.ncpu) + BUILD_DIR=build_ios_${{ matrix.platform }} + cmake --build "$BUILD_DIR" \ + --target diskann_mobile_compat_test diskann_mobile_collection_test \ + --parallel "$NPROC" + + for test_name in diskann_mobile_compat_test diskann_mobile_collection_test; do + test_app="$BUILD_DIR/bin/${test_name}.app" + if [ ! -d "$test_app" ]; then + echo "::error::Missing required iOS DiskAnn test app: $test_app" + exit 1 + fi + done + - name: Build test targets if: matrix.test_on_simulator run: | @@ -70,6 +105,11 @@ jobs: - name: Boot iOS Simulator if: matrix.test_on_simulator run: | + if [ "$(uname -m)" != "arm64" ]; then + echo "::error::SIMULATORARM64 tests require an arm64 macOS runner" + exit 1 + fi + DEVICE_ID=$(xcrun simctl list devices available -j \ | python3 -c " import json, sys @@ -84,6 +124,7 @@ jobs: ") echo "DEVICE_ID=$DEVICE_ID" >> $GITHUB_ENV xcrun simctl boot "$DEVICE_ID" + xcrun simctl bootstatus "$DEVICE_ID" -b echo "Booted simulator: $DEVICE_ID" - name: Run all tests on simulator @@ -92,52 +133,167 @@ jobs: FAILED_TESTS="" PASSED=0 TOTAL=0 + DISKANN_COMPAT_PASSED=0 + DISKANN_COLLECTION_PASSED=0 + BUILD_DIR=build_ios_${{ matrix.platform }} - for APP in build_ios_${{ matrix.platform }}/bin/*_test.app; do + for test_name in diskann_mobile_compat_test diskann_mobile_collection_test; do + test_app="$BUILD_DIR/bin/${test_name}.app" + if [ ! -d "$test_app" ]; then + echo "::error::Missing required iOS DiskAnn test app: $test_app" + exit 1 + fi + done + + for APP in "$BUILD_DIR"/bin/*_test.app; do [ -d "$APP" ] || continue TEST_NAME=$(basename "$APP" .app) + + # All test cases in this legacy target are disabled with #if 0. + # Do not count an empty GTest binary as an executed test suite. + if [ "$TEST_NAME" = "cosine_distance_matrix_int8_test" ]; then + echo "::notice::Skipping ${TEST_NAME}: it contains no enabled GTest cases" + continue + fi + BUNDLE_ID="com.zvec.${TEST_NAME}" + LOG_FILE="$RUNNER_TEMP/${TEST_NAME}.log" TOTAL=$((TOTAL + 1)) echo "::group::Running ${TEST_NAME}" xcrun simctl install "$DEVICE_ID" "$APP" - set +eo pipefail for attempt in 1 2 3; do - xcrun simctl launch --console "$DEVICE_ID" "$BUNDLE_ID" 2>&1 | tee /tmp/${TEST_NAME}.log - LAUNCH_EXIT=${PIPESTATUS[0]} - if ! grep -q "unknown to FrontBoard" /tmp/${TEST_NAME}.log; then + set +e + python3 - "$DEVICE_ID" "$BUNDLE_ID" "$LOG_FILE" <<'PY' + import os + import pathlib + import subprocess + import sys + + device_id, bundle_id, log_file = sys.argv[1:] + env = os.environ.copy() + env["SIMCTL_CHILD_GTEST_COLOR"] = "no" + try: + result = subprocess.run( + ["xcrun", "simctl", "launch", "--console", device_id, bundle_id], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=600, + ) + output = result.stdout or "" + return_code = result.returncode + except subprocess.TimeoutExpired as error: + output = error.stdout or "" + if isinstance(output, bytes): + output = output.decode("utf-8", errors="replace") + output += "\nTest timed out after 600 seconds.\n" + return_code = 124 + + pathlib.Path(log_file).write_text(output, encoding="utf-8") + print(output, end="") + sys.exit(return_code) + PY + LAUNCH_EXIT=$? + set -e + + SUMMARY_FOUND=0 + if grep -Eq '^\[==========\][[:space:]]+[0-9]+ tests?[[:space:]]+from[[:space:]]+[0-9]+ test (suites?|cases?)[[:space:]]+ran\.' "$LOG_FILE"; then + SUMMARY_FOUND=1 + elif [ "$TEST_NAME" = "c_api_test" ] && grep -Eq '^Failed: [0-9]+[[:space:]]*$' "$LOG_FILE"; then + SUMMARY_FOUND=1 + fi + + RETRY_REASON="" + if grep -q "unknown to FrontBoard" "$LOG_FILE"; then + RETRY_REASON="FrontBoard has not registered ${TEST_NAME} yet" + elif [ "$LAUNCH_EXIT" -eq 0 ] && [ "$SUMMARY_FOUND" -eq 0 ] && \ + ! grep -Eq '^\[ FAILED \]' "$LOG_FILE"; then + RETRY_REASON="${TEST_NAME} exited without a complete test summary" + else break fi - echo "::warning::Attempt ${attempt}/3: FrontBoard has not registered ${TEST_NAME} yet, retrying in 3s..." + + if [ "$attempt" -eq 3 ]; then + break + fi + + echo "::warning::Attempt ${attempt}/3: ${RETRY_REASON}; reinstalling and retrying in 3s..." + xcrun simctl terminate "$DEVICE_ID" "$BUNDLE_ID" 2>/dev/null || true + xcrun simctl uninstall "$DEVICE_ID" "$BUNDLE_ID" 2>/dev/null || true sleep 3 + xcrun simctl install "$DEVICE_ID" "$APP" done - set -eo pipefail - if grep -q '\[ FAILED \]' /tmp/${TEST_NAME}.log; then + EXPECTED_COUNT="" + case "$TEST_NAME" in + diskann_mobile_compat_test) + EXPECTED_COUNT=7 + ;; + diskann_mobile_collection_test) + EXPECTED_COUNT=4 + ;; + esac + + if [ "$LAUNCH_EXIT" -eq 124 ]; then + echo "::error::${TEST_NAME} timed out after 600 seconds" + xcrun simctl terminate "$DEVICE_ID" "$BUNDLE_ID" 2>/dev/null || true + FAILED_TESTS="${FAILED_TESTS} ${TEST_NAME}" + elif [ "$LAUNCH_EXIT" -ne 0 ]; then + echo "::error::${TEST_NAME} launch exited ${LAUNCH_EXIT}" + FAILED_TESTS="${FAILED_TESTS} ${TEST_NAME}" + elif grep -Eq '^\[ FAILED \]' "$LOG_FILE"; then echo "::error::${TEST_NAME} has failing tests" FAILED_TESTS="${FAILED_TESTS} ${TEST_NAME}" - elif grep -q '\[ PASSED \]' /tmp/${TEST_NAME}.log; then + elif [ -n "$EXPECTED_COUNT" ] && \ + ! grep -Eq "^\\[ PASSED \\][[:space:]]+${EXPECTED_COUNT} tests?\\.[[:space:]]*$" "$LOG_FILE"; then + echo "::error::${TEST_NAME} did not report the required ${EXPECTED_COUNT}/${EXPECTED_COUNT} passing tests" + FAILED_TESTS="${FAILED_TESTS} ${TEST_NAME}" + elif [ -n "$EXPECTED_COUNT" ] && \ + ! grep -Eq "^\\[==========\\][[:space:]]+${EXPECTED_COUNT} tests?[[:space:]]+from[[:space:]]+[1-9][0-9]* test (suites?|cases?)[[:space:]]+ran\\." "$LOG_FILE"; then + echo "::error::${TEST_NAME} did not run exactly ${EXPECTED_COUNT} tests" + FAILED_TESTS="${FAILED_TESTS} ${TEST_NAME}" + elif grep -Eq '^\[==========\][[:space:]]+[1-9][0-9]* tests?[[:space:]]+from[[:space:]]+[1-9][0-9]* test (suites?|cases?)[[:space:]]+ran\.' "$LOG_FILE"; then PASSED=$((PASSED + 1)) - elif grep -qE 'Failed: 0$' /tmp/${TEST_NAME}.log; then + if [ "$TEST_NAME" = "diskann_mobile_compat_test" ]; then + DISKANN_COMPAT_PASSED=1 + elif [ "$TEST_NAME" = "diskann_mobile_collection_test" ]; then + DISKANN_COLLECTION_PASSED=1 + fi + elif [ "$TEST_NAME" = "c_api_test" ] && \ + grep -Eq '^Passed: [1-9][0-9]*[[:space:]]*$' "$LOG_FILE" && \ + grep -Eq '^Failed: 0[[:space:]]*$' "$LOG_FILE"; then # c_api_test uses a custom test framework (not GTest) PASSED=$((PASSED + 1)) - elif [ "$LAUNCH_EXIT" -eq 0 ]; then - echo "::warning::${TEST_NAME} exited 0 but produced no recognisable test summary" - PASSED=$((PASSED + 1)) else - echo "::error::${TEST_NAME} exited ${LAUNCH_EXIT} with no test summary" + echo "::error::${TEST_NAME} produced no recognisable passing test summary" FAILED_TESTS="${FAILED_TESTS} ${TEST_NAME}" fi echo "::endgroup::" done echo "Test summary: ${PASSED}/${TOTAL} passed" + if [ "$TOTAL" -eq 0 ]; then + echo "::error::No iOS test apps were discovered" + exit 1 + fi if [ -n "$FAILED_TESTS" ]; then echo "::error::Failed tests:${FAILED_TESTS}" exit 1 fi + if [ "$DISKANN_COMPAT_PASSED" -ne 1 ] || [ "$DISKANN_COLLECTION_PASSED" -ne 1 ]; then + echo "::error::Required DiskAnn tests did not both complete successfully" + exit 1 + fi + if [ "$PASSED" -ne "$TOTAL" ]; then + echo "::error::Only ${PASSED}/${TOTAL} iOS tests completed successfully" + exit 1 + fi - name: Shutdown Simulator if: matrix.test_on_simulator && always() run: | - xcrun simctl shutdown "$DEVICE_ID" || true + if [ -n "${DEVICE_ID:-}" ]; then + xcrun simctl shutdown "$DEVICE_ID" || true + fi diff --git a/CMakeLists.txt b/CMakeLists.txt index be58fc7a9..c4cd369aa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -114,13 +114,22 @@ endif() include_directories(${PROJECT_ROOT_DIR}/src/include) include_directories(${PROJECT_ROOT_DIR}/src) -option(BUILD_ZVEC_SHARED "Build all-in-one C++ shared library libzvec" ON) -option(BUILD_ZVEC_AILEGO_SHARED "Build all-in-one zvec-ailego shared library libzvec_ailego" ON) -option(BUILD_ZVEC_CORE_SHARED "Build all-in-one zvec-core shared library libzvec_core" ON) +set(ZVEC_CPP_SHARED_DEFAULT ON) +if(ANDROID OR IOS) + # A C++ shared library built with a static libc++ cannot safely exchange STL + # objects with a mobile application. Mobile C++ consumers use the static SDK + # targets below; the shared C API remains available through BUILD_C_BINDINGS. + set(ZVEC_CPP_SHARED_DEFAULT OFF) +endif() + +option(BUILD_ZVEC_SHARED "Build all-in-one C++ shared library libzvec" ${ZVEC_CPP_SHARED_DEFAULT}) +option(BUILD_ZVEC_AILEGO_SHARED "Build all-in-one zvec-ailego shared library libzvec_ailego" ${ZVEC_CPP_SHARED_DEFAULT}) +option(BUILD_ZVEC_CORE_SHARED "Build all-in-one zvec-core shared library libzvec_core" ${ZVEC_CPP_SHARED_DEFAULT}) option(BUILD_PYTHON_BINDINGS "Build Python bindings using pybind11" OFF) option(BUILD_C_BINDINGS "Build C bindings" ON) option(BUILD_TOOLS "Build tools" ON) +option(BUILD_CPP_EXAMPLES "Build C++ examples" OFF) message(STATUS "BUILD_ZVEC_SHARED:${BUILD_ZVEC_SHARED}") message(STATUS "BUILD_ZVEC_AILEGO_SHARED:${BUILD_ZVEC_AILEGO_SHARED}") @@ -128,6 +137,7 @@ message(STATUS "BUILD_ZVEC_CORE_SHARED:${BUILD_ZVEC_CORE_SHARED}") message(STATUS "BUILD_PYTHON_BINDINGS:${BUILD_PYTHON_BINDINGS}") message(STATUS "BUILD_C_BINDINGS:${BUILD_C_BINDINGS}") message(STATUS "BUILD_TOOLS:${BUILD_TOOLS}") +message(STATUS "BUILD_CPP_EXAMPLES:${BUILD_CPP_EXAMPLES}") if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|amd64|AMD64" AND NOT ANDROID AND NOT IOS) include(CheckCXXCompilerFlag) @@ -163,10 +173,12 @@ message(STATUS "RABITQ_SUPPORTED: ${RABITQ_SUPPORTED}") # DiskAnn support: # - Linux x86_64 and ARM64 with io_uring, libaio, or pread +# - 64-bit Android and iOS with the portable synchronous pread backend # - macOS ARM64 (Apple Silicon) with synchronous pread # - Windows x86_64 with overlapped I/O if((CMAKE_SYSTEM_NAME STREQUAL "Linux" AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64|AMD64|aarch64|arm64)$" AND NOT ANDROID AND NOT IOS) OR (CMAKE_SYSTEM_NAME STREQUAL "Darwin" AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64)$" AND NOT IOS) + OR ((ANDROID OR IOS) AND CMAKE_SIZEOF_VOID_P EQUAL 8) OR (WIN32 AND CMAKE_SIZEOF_VOID_P EQUAL 8 AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64|amd64)$")) set(DISKANN_SUPPORTED ON) add_definitions(-DDISKANN_SUPPORTED=1) @@ -186,6 +198,10 @@ message(STATUS "USE_OSS_MIRROR:${USE_OSS_MIRROR}") cc_directory(thirdparty) cc_directories(src) +if(BUILD_CPP_EXAMPLES) + add_subdirectory(examples/c++ EXCLUDE_FROM_ALL) +endif() + cc_directories(tests) add_custom_target(clang_tidy_deps DEPENDS ARROW.BUILD glog gflags Lz4.BUILD) diff --git a/cmake/bazel.cmake b/cmake/bazel.cmake index e6e29f189..d3375048d 100644 --- a/cmake/bazel.cmake +++ b/cmake/bazel.cmake @@ -619,8 +619,9 @@ endfunction() ## Add both shared and static library macro(_add_library _NAME _OPTION) add_library(${_NAME}_objects OBJECT ${_OPTION} ${ARGN}) - if(IOS) - # iOS has no shared libraries, so the main target is static as well. + if(IOS OR (ANDROID AND ANDROID_STL STREQUAL "c++_static")) + # Mobile c++_static builds expose one archive under both target names so + # the C++ runtime is linked into an executable exactly once. # Building a second, identical archive under the ${_NAME}_static name is # not just wasteful, it breaks the build: giving both the same OUTPUT_NAME # makes Ninja fail ("multiple rules generate ..."), while distinct names @@ -645,7 +646,7 @@ macro(_add_library _NAME _OPTION) endmacro() ## Check whether _static is a target of its own rather than an alias of -## (see _add_library: on iOS the two names share a single archive). +## (see _add_library: mobile c++_static builds share one archive). function(_has_own_static_variant _RESULT _NAME) set(${_RESULT} FALSE PARENT_SCOPE) if(NOT TARGET ${_NAME}_static) diff --git a/examples/c++/CMakeLists.txt b/examples/c++/CMakeLists.txt index bde2d0ab8..ecb138c17 100644 --- a/examples/c++/CMakeLists.txt +++ b/examples/c++/CMakeLists.txt @@ -1,6 +1,8 @@ cmake_minimum_required(VERSION 3.13) cmake_policy(SET CMP0077 NEW) -project(zvec-example-c++) +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + project(zvec-example-c++) +endif() set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -15,88 +17,110 @@ endif() get_filename_component(ZVEC_ROOT_DIR "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) set(ZVEC_INCLUDE_DIR ${ZVEC_ROOT_DIR}/src/include) -set(ZVEC_LIB_DIR ${ZVEC_ROOT_DIR}/${HOST_BUILD_DIR}/lib) - include_directories(${ZVEC_INCLUDE_DIR}) -set(ZVEC_LIB_SEARCH_DIRS ${ZVEC_LIB_DIR}) -# Support multi-config builds (MSVC puts libs in Debug/Release subdirectories) -if(CMAKE_BUILD_TYPE) - set(ZVEC_CONFIG_LIB_DIR ${ZVEC_LIB_DIR}/${CMAKE_BUILD_TYPE}) - if(EXISTS "${ZVEC_CONFIG_LIB_DIR}") - list(APPEND ZVEC_LIB_SEARCH_DIRS ${ZVEC_CONFIG_LIB_DIR}) +if(ANDROID OR IOS) + if(NOT TARGET zvec::static OR + NOT TARGET zvec::core_static OR + NOT TARGET zvec::ailego_static) + message(FATAL_ERROR + "Mobile C++ examples must be built from the zvec root with " + "-DBUILD_CPP_EXAMPLES=ON so they use the static SDK targets.") endif() -endif() -if(WIN32) - set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") -endif() -function(zvec_find_shared_library OUT_VAR LIB_NAME) - unset(${OUT_VAR} CACHE) + add_library(zvec-lib INTERFACE) + target_link_libraries(zvec-lib INTERFACE zvec::static) + + add_library(zvec-ailego-lib INTERFACE) + target_link_libraries(zvec-ailego-lib INTERFACE zvec::ailego_static) + + add_library(zvec-core-lib INTERFACE) + target_link_libraries(zvec-core-lib INTERFACE zvec::core_static) +elseif(TARGET zvec_shared AND + TARGET zvec_core_shared AND + TARGET zvec_ailego_shared) + # An in-tree desktop build can link targets directly; the shared-library + # files do not need to exist yet during CMake configuration. + add_library(zvec-lib INTERFACE) + target_link_libraries(zvec-lib INTERFACE zvec_shared) + + add_library(zvec-ailego-lib INTERFACE) + target_link_libraries(zvec-ailego-lib INTERFACE zvec_ailego_shared) + + add_library(zvec-core-lib INTERFACE) + target_link_libraries(zvec-core-lib INTERFACE zvec_core_shared) +else() + set(ZVEC_LIB_DIR ${ZVEC_ROOT_DIR}/${HOST_BUILD_DIR}/lib) + set(ZVEC_LIB_SEARCH_DIRS ${ZVEC_LIB_DIR}) + + # Support multi-config builds (MSVC puts libs in Debug/Release subdirectories) + if(CMAKE_BUILD_TYPE) + set(ZVEC_CONFIG_LIB_DIR ${ZVEC_LIB_DIR}/${CMAKE_BUILD_TYPE}) + if(EXISTS "${ZVEC_CONFIG_LIB_DIR}") + list(APPEND ZVEC_LIB_SEARCH_DIRS ${ZVEC_CONFIG_LIB_DIR}) + endif() + endif() if(WIN32) - find_library(${OUT_VAR} - NAMES ${LIB_NAME}_shared ${LIB_NAME} - PATHS ${ZVEC_LIB_SEARCH_DIRS} - NO_DEFAULT_PATH - NO_CMAKE_FIND_ROOT_PATH - ) - else() - set(ZVEC_ORIGINAL_LIBRARY_SUFFIXES ${CMAKE_FIND_LIBRARY_SUFFIXES}) - if(APPLE) - set(CMAKE_FIND_LIBRARY_SUFFIXES ".dylib") + set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + endif() + + function(zvec_find_shared_library OUT_VAR LIB_NAME) + unset(${OUT_VAR} CACHE) + if(WIN32) + find_library(${OUT_VAR} + NAMES ${LIB_NAME}_shared ${LIB_NAME} + PATHS ${ZVEC_LIB_SEARCH_DIRS} + NO_DEFAULT_PATH + NO_CMAKE_FIND_ROOT_PATH + ) else() - set(CMAKE_FIND_LIBRARY_SUFFIXES ".so") + set(ZVEC_ORIGINAL_LIBRARY_SUFFIXES ${CMAKE_FIND_LIBRARY_SUFFIXES}) + if(APPLE) + set(CMAKE_FIND_LIBRARY_SUFFIXES ".dylib") + else() + set(CMAKE_FIND_LIBRARY_SUFFIXES ".so") + endif() + find_library(${OUT_VAR} + NAMES ${LIB_NAME} + PATHS ${ZVEC_LIB_SEARCH_DIRS} + NO_DEFAULT_PATH + NO_CMAKE_FIND_ROOT_PATH + ) + set(CMAKE_FIND_LIBRARY_SUFFIXES "${ZVEC_ORIGINAL_LIBRARY_SUFFIXES}") endif() - find_library(${OUT_VAR} - NAMES ${LIB_NAME} - PATHS ${ZVEC_LIB_SEARCH_DIRS} - NO_DEFAULT_PATH - NO_CMAKE_FIND_ROOT_PATH - ) - set(CMAKE_FIND_LIBRARY_SUFFIXES "${ZVEC_ORIGINAL_LIBRARY_SUFFIXES}") - endif() - set(${OUT_VAR} "${${OUT_VAR}}" PARENT_SCOPE) -endfunction() + set(${OUT_VAR} "${${OUT_VAR}}" PARENT_SCOPE) + endfunction() + + function(zvec_require_shared_library OUT_VAR LIB_NAME) + zvec_find_shared_library(${OUT_VAR} ${LIB_NAME}) + if(NOT ${OUT_VAR}) + message(FATAL_ERROR + "lib${LIB_NAME} shared library was not found in ${ZVEC_LIB_SEARCH_DIRS}. " + "Build zvec first, or pass -DHOST_BUILD_DIR=.") + endif() + set(${OUT_VAR} "${${OUT_VAR}}" PARENT_SCOPE) + endfunction() -function(zvec_require_shared_library OUT_VAR LIB_NAME) - zvec_find_shared_library(${OUT_VAR} ${LIB_NAME}) - if(NOT ${OUT_VAR}) - message(FATAL_ERROR - "lib${LIB_NAME} shared library was not found in ${ZVEC_LIB_SEARCH_DIRS}. " - "Build zvec first, or pass -DHOST_BUILD_DIR=.") - endif() - set(${OUT_VAR} "${${OUT_VAR}}" PARENT_SCOPE) -endfunction() - -zvec_require_shared_library(ZVEC_SHARED_LIBRARY zvec) -zvec_require_shared_library(ZVEC_AILEGO_SHARED_LIBRARY zvec_ailego) -zvec_require_shared_library(ZVEC_CORE_SHARED_LIBRARY zvec_core) - -# --- Create INTERFACE target for libzvec (all-in-one C++ shared library) --- -# libzvec.so/.dylib/.dll already bundles all zvec internal components -# (zvec, zvec_core, zvec_ailego, zvec_turbo), so no individual dependency -# libraries need to be specified by the consumer. -add_library(zvec-lib INTERFACE) -target_link_libraries(zvec-lib INTERFACE "${ZVEC_SHARED_LIBRARY}") - -# --- Create INTERFACE target for libzvec_ailego (ailego-only all-in-one library) --- -# The ailego example intentionally depends only on libzvec_ailego. -add_library(zvec-ailego-lib INTERFACE) -target_link_libraries(zvec-ailego-lib INTERFACE "${ZVEC_AILEGO_SHARED_LIBRARY}") - -# --- Create INTERFACE target for libzvec_core (core-only all-in-one library) --- -# The core example intentionally depends only on libzvec_core. -add_library(zvec-core-lib INTERFACE) -target_link_libraries(zvec-core-lib INTERFACE "${ZVEC_CORE_SHARED_LIBRARY}") + zvec_require_shared_library(ZVEC_SHARED_LIBRARY zvec) + zvec_require_shared_library(ZVEC_AILEGO_SHARED_LIBRARY zvec_ailego) + zvec_require_shared_library(ZVEC_CORE_SHARED_LIBRARY zvec_core) + + # Desktop examples keep using the public all-in-one shared libraries. + add_library(zvec-lib INTERFACE) + target_link_libraries(zvec-lib INTERFACE "${ZVEC_SHARED_LIBRARY}") + + add_library(zvec-ailego-lib INTERFACE) + target_link_libraries(zvec-ailego-lib INTERFACE "${ZVEC_AILEGO_SHARED_LIBRARY}") + + add_library(zvec-core-lib INTERFACE) + target_link_libraries(zvec-core-lib INTERFACE "${ZVEC_CORE_SHARED_LIBRARY}") +endif() # --- Executables --- set(ZVEC_EXAMPLE_TARGETS) add_executable(db-example db/main.cc) target_link_libraries(db-example PRIVATE zvec-lib) -if(ANDROID) - target_link_libraries(db-example PRIVATE log) -endif() list(APPEND ZVEC_EXAMPLE_TARGETS db-example) add_executable(ailego-example ailego/main.cc) @@ -117,6 +141,8 @@ add_executable(diskann-core-example core/diskann_main.cc) target_link_libraries(diskann-core-example PRIVATE zvec-core-lib) list(APPEND ZVEC_EXAMPLE_TARGETS diskann-core-example) +add_custom_target(zvec_cpp_examples DEPENDS ${ZVEC_EXAMPLE_TARGETS}) + # Strip symbols to reduce executable size if(CMAKE_BUILD_TYPE STREQUAL "Release" AND ANDROID) foreach(ZVEC_EXAMPLE_TARGET ${ZVEC_EXAMPLE_TARGETS}) diff --git a/examples/c/diskann_example.c b/examples/c/diskann_example.c index d93624917..d65ac0596 100644 --- a/examples/c/diskann_example.c +++ b/examples/c/diskann_example.c @@ -21,7 +21,8 @@ * a Vamana graph structure combined with product quantization (PQ) to * achieve high recall with efficient disk I/O. * - * NOTE: DiskANN is available on Linux x86_64/ARM64 and macOS ARM64. + * NOTE: DiskANN is available on Linux x86_64/ARM64 and macOS ARM64, and on + * Android and iOS through the portable synchronous pread backend. * * Workflow demonstrated: * 1. Create collection schema with DiskANN-indexed vector field diff --git a/examples/c/optimized_example.c b/examples/c/optimized_example.c index 28be5c2a2..1acc76eb9 100644 --- a/examples/c/optimized_example.c +++ b/examples/c/optimized_example.c @@ -43,7 +43,7 @@ static float *create_test_vector(size_t dimension) { } for (size_t i = 0; i < dimension; i++) { - vector[i] = (float)rand() / RAND_MAX; + vector[i] = (float)rand() / (float)RAND_MAX; } return vector; @@ -307,4 +307,4 @@ int main() { printf("✓ Optimized example completed\n"); return 0; -} \ No newline at end of file +} diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 54337f92f..a7de8ccd5 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -58,8 +58,12 @@ cmake -S . -B "$BUILD_DIR" -G Ninja \ -DANDROID_NATIVE_API_LEVEL="$API_LEVEL" \ -DANDROID_STL="c++_static" \ -DCMAKE_BUILD_TYPE="$BUILD_TYPE" \ + -DBUILD_ZVEC_SHARED=OFF \ + -DBUILD_ZVEC_AILEGO_SHARED=OFF \ + -DBUILD_ZVEC_CORE_SHARED=OFF \ -DBUILD_PYTHON_BINDINGS=OFF \ -DBUILD_TOOLS=OFF \ + -DBUILD_CPP_EXAMPLES=ON \ -DENABLE_NATIVE=OFF \ -DAUTO_DETECT_ARCH=OFF \ -DCMAKE_INSTALL_PREFIX="$BUILD_DIR/install" \ @@ -360,4 +364,33 @@ if [ $FAILED -gt 0 ]; then exit 1 fi +echo "" +echo ">>> Step 6: Running statically linked C++ examples..." +cmake --build "$BUILD_DIR" --target zvec_cpp_examples -j"$CORE_COUNT" +READELF=$(find "$ANDROID_NDK_HOME/toolchains/llvm/prebuilt" -type f -name llvm-readelf | head -1) +if [ -z "$READELF" ]; then + echo "ERROR: llvm-readelf was not found in $ANDROID_NDK_HOME" + exit 1 +fi + +for example in ailego-example core-example external-vector-example db-example; do + example_path="$BUILD_DIR/bin/$example" + if [ ! -f "$example_path" ]; then + echo "ERROR: Example binary not found: $example_path" + exit 1 + fi + + dynamic_section=$("$READELF" --dynamic "$example_path") + if echo "$dynamic_section" | grep -Eq 'libzvec[^]]*\.so|libc\+\+_shared\.so'; then + echo "ERROR: $example unexpectedly depends on a C++ shared library" + echo "$dynamic_section" | grep NEEDED || true + exit 1 + fi + + echo " Running $example..." + $ADB_BIN push "$example_path" "/data/local/tmp/$example" > /dev/null 2>&1 + $ADB_BIN shell "chmod 755 /data/local/tmp/$example && cd /data/local/tmp && ./$example" + $ADB_BIN shell "rm -f /data/local/tmp/$example" +done + echo "All tests passed!" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0f28f7431..51cbd75a1 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -215,6 +215,89 @@ function(zvec_add_all_in_one_shared TARGET_NAME OUTPUT_NAME) ) endfunction() +# Mobile public C++ API. These build-tree targets keep the application and +# zvec in one C++ runtime when the NDK/iOS toolchain uses a static libc++. +# Whole-archive is required because module registration is performed by static +# initializers that otherwise have no referenced symbol at link time. +function(zvec_add_mobile_static_sdk TARGET_NAME) + cmake_parse_arguments(ZVEC_STATIC_SDK "" "" "LIBS" ${ARGN}) + if(NOT ZVEC_STATIC_SDK_LIBS) + message(FATAL_ERROR "zvec_add_mobile_static_sdk requires LIBS") + endif() + + foreach(ZVEC_STATIC_SDK_LIB ${ZVEC_STATIC_SDK_LIBS}) + if(NOT TARGET ${ZVEC_STATIC_SDK_LIB}) + message(FATAL_ERROR + "Target ${ZVEC_STATIC_SDK_LIB} is required by ${TARGET_NAME}") + endif() + endforeach() + + add_library(${TARGET_NAME} INTERFACE) + target_compile_features(${TARGET_NAME} INTERFACE cxx_std_17) + target_include_directories(${TARGET_NAME} + INTERFACE + $ + $ + ) + + if(IOS) + set(ZVEC_STATIC_SDK_LINK_OPTIONS) + foreach(ZVEC_STATIC_SDK_LIB ${ZVEC_STATIC_SDK_LIBS}) + list(APPEND ZVEC_STATIC_SDK_LINK_OPTIONS + -Wl,-force_load,$ + ) + endforeach() + target_link_options(${TARGET_NAME} + INTERFACE ${ZVEC_STATIC_SDK_LINK_OPTIONS} + ) + target_link_libraries(${TARGET_NAME} + INTERFACE + ${ZVEC_STATIC_SDK_LIBS} + Threads::Threads + ${CMAKE_DL_LIBS} + ) + else() + # Keep whole-archive scoped to the SDK archives themselves. Putting + # these flags in target_link_libraries() also encloses transitive + # dependencies inserted by CMake, which forces both Arrow's bundled + # utf8proc and zvec's standalone utf8proc into the executable. + set(ZVEC_STATIC_SDK_LINK_OPTIONS) + foreach(ZVEC_STATIC_SDK_LIB ${ZVEC_STATIC_SDK_LIBS}) + list(APPEND ZVEC_STATIC_SDK_LINK_OPTIONS + -Wl,--whole-archive,$,--no-whole-archive + ) + endforeach() + target_link_options(${TARGET_NAME} + INTERFACE ${ZVEC_STATIC_SDK_LINK_OPTIONS} + ) + target_link_libraries(${TARGET_NAME} + INTERFACE + ${ZVEC_STATIC_SDK_LIBS} + Threads::Threads + ${CMAKE_DL_LIBS} + ) + if(ANDROID) + target_link_libraries(${TARGET_NAME} INTERFACE log) + endif() + endif() +endfunction() + +if(ANDROID OR IOS) + zvec_add_mobile_static_sdk(zvec_static_sdk + LIBS zvec zvec_core zvec_ailego zvec_turbo + ) + zvec_add_mobile_static_sdk(zvec_core_static_sdk + LIBS zvec_core zvec_ailego zvec_turbo + ) + zvec_add_mobile_static_sdk(zvec_ailego_static_sdk + LIBS zvec_ailego + ) + + add_library(zvec::static ALIAS zvec_static_sdk) + add_library(zvec::core_static ALIAS zvec_core_static_sdk) + add_library(zvec::ailego_static ALIAS zvec_ailego_static_sdk) +endif() + if(BUILD_ZVEC_AILEGO_SHARED) zvec_add_all_in_one_shared(zvec_ailego_shared zvec_ailego LIBS diff --git a/src/core/algorithm/CMakeLists.txt b/src/core/algorithm/CMakeLists.txt index 7931bf344..01aa37513 100644 --- a/src/core/algorithm/CMakeLists.txt +++ b/src/core/algorithm/CMakeLists.txt @@ -17,7 +17,7 @@ else() # Empty stub library for unsupported platforms file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/diskann_stub.cc "// Stub implementation for unsupported platforms\n" - "// DiskAnn supports Linux (x86_64/ARM64) and macOS ARM64\n" + "// DiskAnn supports Linux/macOS, 64-bit Android/iOS, and Windows x86_64\n" "namespace zvec { namespace core { /* empty namespace for compatibility */ } }\n" ) diff --git a/src/core/algorithm/diskann/diskann_file_reader.cc b/src/core/algorithm/diskann/diskann_file_reader.cc index f4e92554f..e9d84cef1 100644 --- a/src/core/algorithm/diskann/diskann_file_reader.cc +++ b/src/core/algorithm/diskann/diskann_file_reader.cc @@ -51,7 +51,7 @@ static void log_diskann_io_backend(ailego::IOBackendType type) { #if (defined(__linux) || defined(__linux__) || defined(__APPLE__) || \ defined(__MACH__) || defined(_WIN32) || defined(_WIN64)) std::call_once(g_io_backend_log_once, [type]() { -#if (defined(__linux) || defined(__linux__)) +#if defined(__linux__) && !defined(__ANDROID__) if (type == ailego::IOBackendType::kPread) { LOG_WARN( "DiskAnn: no async I/O backend available: io_uring is unavailable " @@ -76,7 +76,7 @@ static void log_diskann_io_backend(ailego::IOBackendType type) { #endif } -#if (defined(__linux) || defined(__linux__)) +#if defined(__linux__) && !defined(__ANDROID__) typedef struct io_event io_event_t; typedef struct iocb iocb_t; @@ -243,7 +243,7 @@ int setup_io_ctx(IOContext &ctx) { #if defined(_WIN32) || defined(_WIN64) log_diskann_io_backend(ctx->type); return 0; -#elif defined(__linux) || defined(__linux__) +#elif defined(__linux__) && !defined(__ANDROID__) if (selected == ailego::IOBackendType::kPread) { log_diskann_io_backend(ctx->type); return 0; @@ -284,7 +284,7 @@ int destroy_io_ctx(IOContext &ctx) { #if defined(_WIN32) || defined(_WIN64) close_windows_io_handles(ctx); -#elif defined(__linux) || defined(__linux__) +#elif defined(__linux__) && !defined(__ANDROID__) if (ctx->type == ailego::IOBackendType::kIoUring) { ctx->ring.teardown(); } else if (ctx->type == ailego::IOBackendType::kLibAio && @@ -342,7 +342,7 @@ static int execute_io_pread(int fd, std::vector &read_reqs) { return 0; } -#if (defined(__linux) || defined(__linux__)) +#if defined(__linux__) && !defined(__ANDROID__) // io_getevents() should only fail permanently for an invalid context or // invalid arguments. If that happens after submission, io_destroy() is the // only safe way to quiesce the context before synchronous I/O touches the same @@ -496,7 +496,7 @@ int execute_io_libaio(io_context_t &ctx, int fd, int execute_io(IOContext ctx, int fd, std::vector &read_reqs, uint64_t n_retries = 0) { -#if (defined(__linux) || defined(__linux__)) +#if defined(__linux__) && !defined(__ANDROID__) // A missing asynchronous context falls back to synchronous pread. if (ctx == nullptr) { return execute_io_pread(fd, read_reqs); @@ -532,7 +532,7 @@ int execute_io(IOContext ctx, int fd, std::vector &read_reqs, // accesses AlignedRead members, and AlignedRead is defined in // diskann_file_reader.h after iouring_loader.h is included. // --------------------------------------------------------------------------- -#if (defined(__linux) || defined(__linux__)) +#if defined(__linux__) && !defined(__ANDROID__) int IoUringRing::execute(int fd, std::vector &read_reqs) { if (!is_valid()) { return -1; @@ -769,7 +769,7 @@ static int duplicate_file_descriptor(int source_fd) { #endif } -#if defined(__linux__) || defined(__linux) +#if defined(__linux__) && !defined(__ANDROID__) static int reopen_file_descriptor_with_direct_io(int source_fd) { // dup()/F_DUPFD_CLOEXEC shares one open-file description with source_fd, so // changing O_DIRECT through F_SETFL would also change the caller's buffered @@ -861,13 +861,13 @@ static void configure_macos_reader(int file_desc, const std::string &fname) { void LinuxAlignedFileReader::open(const std::string &fname) { int flags = O_RDONLY; -#if defined(__linux__) || defined(__linux) +#if defined(__linux__) && !defined(__ANDROID__) flags |= O_DIRECT | O_LARGEFILE; #endif this->file_desc = ::open(fname.c_str(), flags); -#if defined(__linux__) || defined(__linux) +#if defined(__linux__) && !defined(__ANDROID__) // O_DIRECT may not be supported on all filesystems (e.g. tmpfs, overlay). // Fall back to regular buffered I/O when it fails. if (this->file_desc == -1) { @@ -903,7 +903,7 @@ int LinuxAlignedFileReader::open_from_handle(const std::string &fname, int duplicate_fd = -1; bool has_independent_file_description = false; -#if defined(__linux__) || defined(__linux) +#if defined(__linux__) && !defined(__ANDROID__) duplicate_fd = reopen_file_descriptor_with_direct_io(source_fd); if (duplicate_fd < 0) { const int direct_errno = errno; @@ -978,7 +978,7 @@ int LinuxAlignedFileReader::read(std::vector &read_reqs, return ret; } -#if (defined(__linux) || defined(__linux__)) +#if defined(__linux__) && !defined(__ANDROID__) int LinuxAlignedFileReader::submit(PendingBatch &batch, std::vector &read_reqs, IOContext &ctx) { diff --git a/src/core/algorithm/diskann/diskann_file_reader.h b/src/core/algorithm/diskann/diskann_file_reader.h index 2d69ffbd2..96a618535 100644 --- a/src/core/algorithm/diskann/diskann_file_reader.h +++ b/src/core/algorithm/diskann/diskann_file_reader.h @@ -18,7 +18,7 @@ #include #include -#if (defined(__linux) || defined(__linux__)) +#if defined(__linux__) && !defined(__ANDROID__) #include // raw-syscall io_uring wrapper (IoUringRing) #include // dlopen-based libaio wrapper #elif defined(_WIN32) || defined(_WIN64) @@ -67,7 +67,7 @@ namespace core { struct IoBackend { ailego::IOBackendType type{ailego::IOBackendType::kPread}; -#if (defined(__linux) || defined(__linux__)) +#if defined(__linux__) && !defined(__ANDROID__) IoUringRing ring{}; io_context_t aio_ctx{nullptr}; #elif defined(_WIN32) || defined(_WIN64) @@ -88,7 +88,7 @@ int setup_io_ctx(IOContext &ctx); int destroy_io_ctx(IOContext &ctx); // Log the current DiskAnn I/O backend (io_uring, libaio, or pread). Probes the -// backend on first call. No-op outside Linux and macOS. +// backend on first call. Android and iOS always use synchronous pread. void log_diskann_io_backend(); struct AlignedRead { @@ -100,7 +100,7 @@ struct AlignedRead { AlignedRead(uint64_t offset, uint64_t len, void *buf) : offset(offset), len(len), buf(buf) { -#if defined(__linux__) || defined(__linux) +#if defined(__linux__) && !defined(__ANDROID__) // O_DIRECT requires 512-byte alignment on Linux. ailego_assert(static_cast(offset) % 512 == 0); ailego_assert(static_cast(len) % 512 == 0); @@ -110,7 +110,7 @@ struct AlignedRead { }; struct PendingBatch { -#if (defined(__linux) || defined(__linux__)) +#if defined(__linux__) && !defined(__ANDROID__) std::vector cbs; std::vector cb_ptrs; #elif defined(_WIN32) || defined(_WIN64) diff --git a/src/core/algorithm/diskann/diskann_pq_trainer.cc b/src/core/algorithm/diskann/diskann_pq_trainer.cc index c84744cb8..27ce1f5e6 100644 --- a/src/core/algorithm/diskann/diskann_pq_trainer.cc +++ b/src/core/algorithm/diskann/diskann_pq_trainer.cc @@ -149,19 +149,26 @@ int DiskAnnPqTrainer::convert_pivot_data( for (size_t cluster = 0; cluster < num_centers; ++cluster) { size_t idx = chunk * num_centers + cluster; - T *pivot_data_ptr = reinterpret_cast(&(full_pivot_data[0])) + - cluster * dim + chunk_offsets[chunk]; - const T *feature_ptr = - reinterpret_cast(centroids[idx].feature()); - for (size_t d = 0; d < chunk_dims[chunk]; ++d) { - pivot_data_ptr[d] = feature_ptr[d]; - } + uint8_t *pivot_data_ptr = + full_pivot_data.data() + + (cluster * dim + chunk_offsets[chunk]) * sizeof(T); + std::memcpy(pivot_data_ptr, centroids[idx].feature(), + chunk_dims[chunk] * sizeof(T)); } } return 0; } +template int DiskAnnPqTrainer::convert_pivot_data( + const IndexMeta &, uint32_t, uint32_t, const std::vector &, + const std::vector &, IndexCluster::CentroidList &, + std::vector &); +template int DiskAnnPqTrainer::convert_pivot_data( + const IndexMeta &, uint32_t, uint32_t, const std::vector &, + const std::vector &, IndexCluster::CentroidList &, + std::vector &); + int DiskAnnPqTrainer::train_pq(IndexThreads::Pointer threads, const IndexMeta &meta, std::string &train_data, size_t num_train, uint32_t num_centers, diff --git a/src/db/index/common/schema.cc b/src/db/index/common/schema.cc index 10c381b57..ea78bfb59 100644 --- a/src/db/index/common/schema.cc +++ b/src/db/index/common/schema.cc @@ -218,6 +218,8 @@ Status FieldSchema::validate() const { } if (index_params_->type() == IndexType::DISKANN) { + // DiskAnn also uses the portable synchronous pread backend on 64-bit + // Android and iOS. // The CMake variable // DISKANN_SUPPORTED (defined in the top-level CMakeLists.txt) is the // single source of truth for platform eligibility — it is also used by @@ -227,12 +229,13 @@ Status FieldSchema::validate() const { // // On Linux, DiskAnn prefers io_uring, then libaio, and falls back to // synchronous pread() if neither async backend is available. On macOS, - // DiskAnn uses synchronous pread(); Windows uses overlapped I/O. + // Android and iOS, DiskAnn uses synchronous pread(); Windows uses + // overlapped I/O. #if !DISKANN_SUPPORTED return Status::NotSupported( "DiskAnn is not supported on this platform. It is available on " - "Linux (x86_64/ARM64), macOS (ARM64), and Windows " - "(x86_64)."); + "Linux (x86_64/ARM64), macOS (ARM64), 64-bit Android/iOS, and " + "Windows (x86_64)."); #endif } diff --git a/tests/c/CMakeLists.txt b/tests/c/CMakeLists.txt index 9f40ef9ca..f2c3ad850 100644 --- a/tests/c/CMakeLists.txt +++ b/tests/c/CMakeLists.txt @@ -18,7 +18,7 @@ file(GLOB_RECURSE ALL_TEST_SRCS *_test.c) foreach(CC_SRCS ${ALL_TEST_SRCS}) get_filename_component(CC_TARGET ${CC_SRCS} NAME_WE) - cc_gtest( + cc_test( NAME ${CC_TARGET} STRICT LIBS zvec_c_api diff --git a/tests/c/c_api_test.c b/tests/c/c_api_test.c index 5a73a98ea..9b7ea432d 100644 --- a/tests/c/c_api_test.c +++ b/tests/c/c_api_test.c @@ -507,11 +507,11 @@ void test_schema_edge_cases(void) { // Test 4: NULL schema parameter handling for all functions zvec_error_code_t err; const char **test_names = NULL; - size_t test_count = 0; + size_t field_name_count = 0; err = zvec_collection_schema_get_all_field_names(NULL, &test_names, - &test_count); + &field_name_count); TEST_ASSERT(err == ZVEC_ERROR_INVALID_ARGUMENT); - TEST_ASSERT(test_count == 0); + TEST_ASSERT(field_name_count == 0); const zvec_field_schema_t *null_field = zvec_collection_schema_get_field(NULL, "test"); @@ -5235,7 +5235,7 @@ void test_performance_benchmarks(void) { // Create random vector float vec[128]; for (int j = 0; j < 128; j++) { - vec[j] = (float)rand() / RAND_MAX; + vec[j] = (float)rand() / (float)RAND_MAX; } zvec_doc_add_field_by_value(batch_docs[i], "vec", ZVEC_DATA_TYPE_VECTOR_FP32, vec, @@ -5276,7 +5276,7 @@ void test_performance_benchmarks(void) { // Test query performance float query_vec[128]; for (int i = 0; i < 128; i++) { - query_vec[i] = (float)rand() / RAND_MAX; + query_vec[i] = (float)rand() / (float)RAND_MAX; } zvec_vector_query_t *query = zvec_vector_query_create(); diff --git a/tests/c/utils.c b/tests/c/utils.c index 61c118849..dfa651d28 100644 --- a/tests/c/utils.c +++ b/tests/c/utils.c @@ -725,12 +725,12 @@ zvec_doc_t *zvec_test_create_doc_null(uint64_t doc_id, break; } - if (err != ZVEC_OK) { // Free field names array before returning if (field_names) { - for (size_t i = 0; i < field_count; i++) { - free((char *)field_names[i]); + for (size_t cleanup_index = 0; cleanup_index < field_count; + cleanup_index++) { + free((char *)field_names[cleanup_index]); } free(field_names); } diff --git a/tests/core/algorithm/diskann/CMakeLists.txt b/tests/core/algorithm/diskann/CMakeLists.txt index 5e2e622e5..41b449325 100644 --- a/tests/core/algorithm/diskann/CMakeLists.txt +++ b/tests/core/algorithm/diskann/CMakeLists.txt @@ -2,16 +2,26 @@ include(${PROJECT_ROOT_DIR}/cmake/bazel.cmake) file(GLOB_RECURSE ALL_TEST_SRCS *_test.cc) -if(WIN32) - list(FILTER ALL_TEST_SRCS EXCLUDE REGEX "/diskann_file_reader_test\\.cc$") +# The full DiskAnn suite repeatedly builds 10k-vector indexes and is intended +# for desktop CI. Mobile CI runs a focused compatibility test that covers the +# portable I/O path, failure recovery, concurrency, and an end-to-end +# build/dump/load/search cycle. +if(ANDROID OR IOS) + # Mobile CI runs the focused portable-I/O and end-to-end compatibility test. + list(FILTER ALL_TEST_SRCS INCLUDE REGEX "diskann_mobile_compat_test\\.cc$") else() - list(FILTER ALL_TEST_SRCS EXCLUDE REGEX - "/diskann_file_reader_windows_test\\.cc$") -endif() + if(WIN32) + list(FILTER ALL_TEST_SRCS EXCLUDE REGEX + "/diskann_file_reader_test\\.cc$|/diskann_mobile_compat_test\\.cc$") + else() + list(FILTER ALL_TEST_SRCS EXCLUDE REGEX + "/diskann_file_reader_windows_test\\.cc$") + endif() -if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") - list(FILTER ALL_TEST_SRCS EXCLUDE REGEX - "/diskann_file_reader_aio_test\\.cc$") + if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + list(FILTER ALL_TEST_SRCS EXCLUDE REGEX + "/diskann_file_reader_aio_test\\.cc$") + endif() endif() foreach(CC_SRCS ${ALL_TEST_SRCS}) diff --git a/tests/core/algorithm/diskann/diskann_mobile_compat_test.cc b/tests/core/algorithm/diskann/diskann_mobile_compat_test.cc new file mode 100644 index 000000000..66e6828d7 --- /dev/null +++ b/tests/core/algorithm/diskann/diskann_mobile_compat_test.cc @@ -0,0 +1,416 @@ +// Copyright 2025-present the zvec project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "diskann_builder.h" +#include "diskann_file_reader.h" +#include "diskann_pq_trainer.h" +#include "diskann_util.h" + +namespace zvec::core { +namespace { + +class TemporaryFile { + public: + TemporaryFile() : fd_(::mkstemp(path_)) {} + + ~TemporaryFile() { + if (fd_ >= 0) { + ::close(fd_); + } + ::unlink(path_); + } + + TemporaryFile(const TemporaryFile &) = delete; + TemporaryFile &operator=(const TemporaryFile &) = delete; + + int fd() const { + return fd_; + } + + const char *path() const { + return path_; + } + + void release_descriptor_and_unlink() { + if (fd_ >= 0) { + ::close(fd_); + fd_ = -1; + } + ::unlink(path_); + } + + private: + char path_[64] = "DiskAnnMobileCompatTest.XXXXXX"; + int fd_{-1}; +}; + +TEST(DiskAnnMobileCompatTest, AlignedAllocationSupportsUnroundedSize) { + constexpr size_t kSize = 400; + constexpr size_t kAlignment = 256; + + void *buffer = nullptr; + DiskAnnUtil::alloc_aligned(&buffer, kSize, kAlignment); + + ASSERT_NE(buffer, nullptr); + EXPECT_EQ(reinterpret_cast(buffer) % kAlignment, 0u); + std::memset(buffer, 0xa5, kSize); + DiskAnnUtil::free_aligned(buffer); +} + +template +void ExpectExactPqPivotCopy(IndexMeta::DataType data_type) { + constexpr uint32_t kDimension = 4; + constexpr uint32_t kCenterCount = 2; + constexpr uint32_t kChunkCount = 2; + const std::vector chunk_dims{2, 2}; + const std::vector chunk_offsets{0, 2, 4}; + const std::array, 4> values{{ + {{1.0F, 2.0F}}, + {{5.0F, 6.0F}}, + {{3.0F, 4.0F}}, + {{7.0F, 8.0F}}, + }}; + + IndexCluster::CentroidList centroids(values.size()); + for (size_t i = 0; i < values.size(); ++i) { + const std::array feature{{T(values[i][0]), T(values[i][1])}}; + centroids[i].set_feature(feature.data(), sizeof(feature)); + } + + IndexMeta meta(data_type, kDimension); + std::vector pivots; + ASSERT_EQ(DiskAnnPqTrainer::convert_pivot_data( + meta, kCenterCount, kChunkCount, chunk_dims, chunk_offsets, + centroids, pivots), + 0); + ASSERT_EQ(pivots.size(), kCenterCount * meta.element_size()); + + std::array actual{}; + std::memcpy(actual.data(), pivots.data(), pivots.size()); + for (size_t i = 0; i < actual.size(); ++i) { + EXPECT_FLOAT_EQ(static_cast(actual[i]), static_cast(i + 1)); + } +} + +TEST(DiskAnnMobileCompatTest, PqPivotConversionCopiesExactChunkWidths) { + ExpectExactPqPivotCopy(IndexMeta::DataType::DT_FP32); + ExpectExactPqPivotCopy(IndexMeta::DataType::DT_FP16); +} + +TEST(DiskAnnMobileCompatTest, PortableReaderReadsAlignedBatch) { + constexpr size_t kBlockSize = 4096; + constexpr size_t kBlockCount = 2; + constexpr size_t kDataSize = kBlockSize * kBlockCount; + + TemporaryFile file; + ASSERT_GE(file.fd(), 0); + + std::vector expected(kDataSize); + std::fill(expected.begin(), expected.begin() + kBlockSize, 0x3c); + std::fill(expected.begin() + kBlockSize, expected.end(), 0xc3); + ASSERT_EQ(::pwrite(file.fd(), expected.data(), expected.size(), 0), + static_cast(expected.size())); + + void *output = nullptr; + DiskAnnUtil::alloc_aligned(&output, kDataSize, kBlockSize); + ASSERT_NE(output, nullptr); + std::memset(output, 0, kDataSize); + + LinuxAlignedFileReader reader; + reader.open(file.path()); + IOContext context{}; + std::vector requests; + requests.emplace_back(0, kBlockSize, output); + requests.emplace_back(kBlockSize, kBlockSize, + static_cast(output) + kBlockSize); + + EXPECT_EQ(reader.read(requests, context), 0); + EXPECT_EQ(std::memcmp(output, expected.data(), expected.size()), 0); + + reader.close(); + DiskAnnUtil::free_aligned(output); +} + +TEST(DiskAnnMobileCompatTest, PortableReaderRejectsShortRead) { + constexpr size_t kBlockSize = 4096; + + TemporaryFile file; + ASSERT_GE(file.fd(), 0); + + std::vector expected(kBlockSize, 0x5a); + ASSERT_EQ(::pwrite(file.fd(), expected.data(), expected.size(), 0), + static_cast(expected.size())); + + void *output = nullptr; + DiskAnnUtil::alloc_aligned(&output, kBlockSize * 2, kBlockSize); + ASSERT_NE(output, nullptr); + + LinuxAlignedFileReader reader; + reader.open(file.path()); + IOContext context{}; + std::vector requests; + requests.emplace_back(0, kBlockSize * 2, output); + + EXPECT_NE(reader.read(requests, context), 0); + + requests.clear(); + requests.emplace_back(0, kBlockSize, output); + EXPECT_EQ(reader.read(requests, context), 0); + EXPECT_EQ(std::memcmp(output, expected.data(), expected.size()), 0); + + reader.close(); + DiskAnnUtil::free_aligned(output); +} + +TEST(DiskAnnMobileCompatTest, PortableReaderRecoversAfterOpenFailure) { + constexpr size_t kBlockSize = 4096; + + TemporaryFile file; + ASSERT_GE(file.fd(), 0); + std::vector expected(kBlockSize, 0x6b); + ASSERT_EQ(::pwrite(file.fd(), expected.data(), expected.size(), 0), + static_cast(expected.size())); + + void *output = nullptr; + DiskAnnUtil::alloc_aligned(&output, kBlockSize, kBlockSize); + ASSERT_NE(output, nullptr); + + LinuxAlignedFileReader reader; + reader.open("DiskAnnMobileCompatTest.missing"); + IOContext context{}; + std::vector requests; + requests.emplace_back(0, kBlockSize, output); + EXPECT_NE(reader.read(requests, context), 0); + + reader.open(file.path()); + EXPECT_EQ(reader.read(requests, context), 0); + EXPECT_EQ(std::memcmp(output, expected.data(), expected.size()), 0); + + reader.close(); + DiskAnnUtil::free_aligned(output); +} + +TEST(DiskAnnMobileCompatTest, PortableReaderSupportsConcurrentReads) { + constexpr size_t kBlockSize = 4096; + constexpr size_t kThreadCount = 4; + constexpr size_t kDataSize = kBlockSize * kThreadCount; + + TemporaryFile file; + ASSERT_GE(file.fd(), 0); + + std::vector expected(kDataSize); + for (size_t i = 0; i < kThreadCount; ++i) { + std::fill(expected.begin() + i * kBlockSize, + expected.begin() + (i + 1) * kBlockSize, + static_cast(i + 1)); + } + ASSERT_EQ(::pwrite(file.fd(), expected.data(), expected.size(), 0), + static_cast(expected.size())); + + std::array outputs{}; + for (void *&output : outputs) { + DiskAnnUtil::alloc_aligned(&output, kBlockSize, kBlockSize); + ASSERT_NE(output, nullptr); + } + + LinuxAlignedFileReader reader; + reader.open(file.path()); + std::array statuses{}; + std::vector threads; + threads.reserve(kThreadCount); + for (size_t i = 0; i < kThreadCount; ++i) { + threads.emplace_back([&, i]() { + IOContext context{}; + std::vector requests; + requests.emplace_back(i * kBlockSize, kBlockSize, outputs[i]); + statuses[i] = reader.read(requests, context); + }); + } + for (auto &thread : threads) { + thread.join(); + } + + for (size_t i = 0; i < kThreadCount; ++i) { + EXPECT_EQ(statuses[i], 0); + EXPECT_EQ( + std::memcmp(outputs[i], expected.data() + i * kBlockSize, kBlockSize), + 0); + DiskAnnUtil::free_aligned(outputs[i]); + } + reader.close(); +} + +TEST(DiskAnnMobileCompatTest, BuildDumpLoadAndSearch) { + constexpr size_t kDimension = 10; + constexpr size_t kDocCount = 64; + constexpr uint64_t kExpectedKey = 12; + + TemporaryFile index_file; + ASSERT_GE(index_file.fd(), 0); + index_file.release_descriptor_and_unlink(); + + IndexMeta meta(IndexMeta::DataType::DT_FP32, kDimension); + meta.set_metric("SquaredEuclidean", 0, ailego::Params()); + + auto holder = + std::make_shared>( + kDimension); + for (size_t i = 0; i < kDocCount; ++i) { + ailego::NumericalVector vector(kDimension, static_cast(i)); + ASSERT_TRUE(holder->emplace(i, vector)); + } + + ailego::Params build_params; + build_params.set("zvec.diskann.builder.max_degree", 16); + build_params.set("zvec.diskann.builder.list_size", 32); + build_params.set("zvec.diskann.builder.max_pq_chunk_num", 2); + build_params.set("zvec.diskann.builder.threads", 2); + + IndexBuilder::Pointer builder = IndexFactory::CreateBuilder("DiskAnnBuilder"); + ASSERT_NE(builder, nullptr); + ASSERT_EQ(builder->init(meta, build_params), 0); + ASSERT_EQ(builder->train(holder), 0); + ASSERT_EQ(builder->build(holder), 0); + + auto dumper = IndexFactory::CreateDumper("FileDumper"); + ASSERT_NE(dumper, nullptr); + ASSERT_EQ(dumper->create(index_file.path()), 0); + ASSERT_EQ(builder->dump(dumper), 0); + ASSERT_EQ(dumper->close(), 0); + + int snapshot_fd = ::open(index_file.path(), O_RDONLY); + ASSERT_GE(snapshot_fd, 0); + struct stat snapshot_stat {}; + ASSERT_EQ(::fstat(snapshot_fd, &snapshot_stat), 0); + ASSERT_GT(snapshot_stat.st_size, 4096); + std::vector snapshot(static_cast(snapshot_stat.st_size)); + ASSERT_EQ(::pread(snapshot_fd, snapshot.data(), snapshot.size(), 0), + static_cast(snapshot.size())); + ASSERT_EQ(::close(snapshot_fd), 0); + + IndexSearcher::Pointer searcher = + IndexFactory::CreateSearcher("DiskAnnSearcher"); + ASSERT_NE(searcher, nullptr); + + ailego::Params search_params; + search_params.set("zvec.diskann.searcher.list_size", 64); + ASSERT_EQ(searcher->init(search_params), 0); + + auto storage = IndexFactory::CreateStorage("FileReadStorage"); + ASSERT_NE(storage, nullptr); + ASSERT_EQ(storage->open(index_file.path(), false), 0); + ASSERT_EQ(searcher->load(storage, IndexMetric::Pointer()), 0); + + auto context = searcher->create_context(); + ASSERT_NE(context, nullptr); + context->set_topk(5); + + ailego::NumericalVector query(kDimension, 12.1f); + IndexQueryMeta query_meta(IndexMeta::DataType::DT_FP32, kDimension); + ASSERT_EQ(searcher->search_impl(query.data(), query_meta, context), 0); + + const auto &result = context->result(); + ASSERT_FALSE(result.empty()); + EXPECT_NE( + std::find_if(result.begin(), result.end(), + [](const auto &item) { return item.key() == kExpectedKey; }), + result.end()); + + IndexStreamer::Pointer first_streamer = + IndexFactory::CreateStreamer("DiskAnnStreamer"); + ASSERT_NE(first_streamer, nullptr); + ASSERT_EQ(first_streamer->init(meta, search_params), 0); + auto first_streamer_storage = IndexFactory::CreateStorage("FileReadStorage"); + ASSERT_NE(first_streamer_storage, nullptr); + ASSERT_EQ(first_streamer_storage->open(index_file.path(), false), 0); + ASSERT_EQ(first_streamer->open(first_streamer_storage), 0); + + IndexStreamer::Pointer second_streamer = + IndexFactory::CreateStreamer("DiskAnnStreamer"); + ASSERT_NE(second_streamer, nullptr); + ASSERT_EQ(second_streamer->init(meta, search_params), 0); + auto second_streamer_storage = IndexFactory::CreateStorage("FileReadStorage"); + ASSERT_NE(second_streamer_storage, nullptr); + ASSERT_EQ(second_streamer_storage->open(index_file.path(), false), 0); + ASSERT_EQ(second_streamer->open(second_streamer_storage), 0); + + auto switching_context = first_streamer->create_context(); + ASSERT_NE(switching_context, nullptr); + switching_context->set_topk(5); + switching_context->set_filter( + [](uint64_t key) { return key != kExpectedKey; }); + ASSERT_EQ( + second_streamer->search_impl(query.data(), query_meta, switching_context), + 0); + ASSERT_EQ(switching_context->result().size(), 1u); + EXPECT_EQ(switching_context->result().front().key(), kExpectedKey); + + context.reset(); + searcher.reset(); + storage.reset(); + + ASSERT_EQ(::truncate(index_file.path(), snapshot.size() - 4096), 0); + searcher = IndexFactory::CreateSearcher("DiskAnnSearcher"); + ASSERT_NE(searcher, nullptr); + ASSERT_EQ(searcher->init(search_params), 0); + storage = IndexFactory::CreateStorage("FileReadStorage"); + ASSERT_NE(storage, nullptr); + int corrupt_open_result = storage->open(index_file.path(), false); + bool corrupt_index_rejected = corrupt_open_result != 0; + if (corrupt_open_result == 0) { + corrupt_index_rejected = + searcher->load(storage, IndexMetric::Pointer()) != 0; + } + EXPECT_TRUE(corrupt_index_rejected); + + searcher.reset(); + storage.reset(); + int restore_fd = ::open(index_file.path(), O_WRONLY | O_TRUNC); + ASSERT_GE(restore_fd, 0); + ASSERT_EQ(::pwrite(restore_fd, snapshot.data(), snapshot.size(), 0), + static_cast(snapshot.size())); + ASSERT_EQ(::fsync(restore_fd), 0); + ASSERT_EQ(::close(restore_fd), 0); + + searcher = IndexFactory::CreateSearcher("DiskAnnSearcher"); + ASSERT_NE(searcher, nullptr); + ASSERT_EQ(searcher->init(search_params), 0); + storage = IndexFactory::CreateStorage("FileReadStorage"); + ASSERT_NE(storage, nullptr); + ASSERT_EQ(storage->open(index_file.path(), false), 0); + ASSERT_EQ(searcher->load(storage, IndexMetric::Pointer()), 0); + context = searcher->create_context(); + ASSERT_NE(context, nullptr); + context->set_topk(5); + ASSERT_EQ(searcher->search_impl(query.data(), query_meta, context), 0); + EXPECT_NE( + std::find_if(context->result().begin(), context->result().end(), + [](const auto &item) { return item.key() == kExpectedKey; }), + context->result().end()); +} + +} // namespace +} // namespace zvec::core diff --git a/tests/db/CMakeLists.txt b/tests/db/CMakeLists.txt index 975726a1e..219d979d2 100644 --- a/tests/db/CMakeLists.txt +++ b/tests/db/CMakeLists.txt @@ -23,10 +23,21 @@ if(APPLE) endif() file(GLOB ALL_TEST_SRCS *_test.cc) + +# The collection DiskAnn stress cases repeatedly rebuild large indexes and are +# intended for desktop CI. Mobile CI exercises DiskAnn through the focused +# diskann_mobile_collection_test target and the core compatibility suite. +if(ANDROID OR IOS) + set(DISKANN_STRESS_TESTS 0) +else() + set(DISKANN_STRESS_TESTS 1) +endif() + foreach(CC_SRCS ${ALL_TEST_SRCS}) get_filename_component(CC_TARGET ${CC_SRCS} NAME_WE) cc_gmock( NAME ${CC_TARGET} STRICT + DEFS DISKANN_STRESS_TESTS=${DISKANN_STRESS_TESTS} LIBS zvec core_knn_flat core_knn_flat_sparse diff --git a/tests/db/collection_test.cc b/tests/db/collection_test.cc index b5de8fdec..9939c0464 100644 --- a/tests/db/collection_test.cc +++ b/tests/db/collection_test.cc @@ -3539,7 +3539,7 @@ TEST_F(CollectionTest, Feature_Optimize_Repeated) { run_repeated_optimize_test( enable_mmap, std::make_shared( MetricType::IP, 10, 4, false, QuantizeType::FP16)); -#if DISKANN_SUPPORTED +#if DISKANN_SUPPORTED && DISKANN_STRESS_TESTS run_repeated_optimize_test( enable_mmap, std::make_shared( MetricType::IP, 10, 4, 0, QuantizeType::UNDEFINED)); @@ -6303,7 +6303,7 @@ TEST_F(CollectionTest, Feature_Optimize_IVF_RABITQ) { } #endif -#if DISKANN_SUPPORTED +#if DISKANN_SUPPORTED && DISKANN_STRESS_TESTS TEST_F(CollectionTest, Feature_Optimize_DiskAnn) { auto func = [](MetricType metric_type, int concurrency) { FileHelper::RemoveDirectory(col_path); diff --git a/tests/db/diskann_mobile_collection_test.cc b/tests/db/diskann_mobile_collection_test.cc new file mode 100644 index 000000000..9db80bab1 --- /dev/null +++ b/tests/db/diskann_mobile_collection_test.cc @@ -0,0 +1,688 @@ +// Copyright 2025-present the zvec project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__APPLE__) +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace zvec { +namespace { + +#if defined(__ANDROID__) || \ + (defined(__APPLE__) && (TARGET_OS_IOS || TARGET_OS_SIMULATOR)) +static_assert(DISKANN_SUPPORTED == 1, + "Android and iOS must compile the DiskAnn mobile contract"); +#endif + +#if DISKANN_SUPPORTED + +constexpr char kCollectionPath[] = "diskann_mobile_collection"; +constexpr char kFp32Field[] = "dense_fp32"; +constexpr char kFp16Field[] = "dense_fp16"; +constexpr char kDynamicField[] = "dense_dynamic"; +constexpr char kGroupByField[] = "dense_group_by"; +constexpr size_t kDimension = 16; +constexpr uint64_t kDocCount = 48; + +std::vector MakeFp32Vector(uint64_t doc_id) { + std::vector result(kDimension); + for (size_t i = 0; i < result.size(); ++i) { + result[i] = static_cast(((doc_id + 3) * (i + 5)) % 23) / 23.0F + + static_cast(doc_id) * 0.01F; + } + return result; +} + +std::vector MakeFp16Vector(uint64_t doc_id) { + auto fp32 = MakeFp32Vector(doc_id); + std::vector result; + result.reserve(fp32.size()); + for (float value : fp32) { + result.emplace_back(value); + } + return result; +} + +CollectionSchema::Ptr MakeSchema(MetricType metric, bool include_fp16 = false, + bool include_dynamic = false, + bool include_group_by = false) { + auto schema = std::make_shared("diskann_mobile"); + schema->set_max_doc_count_per_segment(1000); + EXPECT_TRUE(schema + ->add_field(std::make_shared( + "category", DataType::INT32, false)) + .ok()); + EXPECT_TRUE(schema + ->add_field(std::make_shared( + "name", DataType::STRING, false)) + .ok()); + + auto diskann = std::make_shared(metric, 16, 32, 2); + EXPECT_TRUE( + schema + ->add_field(std::make_shared( + kFp32Field, DataType::VECTOR_FP32, kDimension, false, diskann)) + .ok()); + if (include_group_by) { + EXPECT_TRUE(schema + ->add_field(std::make_shared( + kGroupByField, DataType::VECTOR_FP32, kDimension, false, + std::make_shared(metric))) + .ok()); + } + if (include_fp16) { + EXPECT_TRUE(schema + ->add_field(std::make_shared( + kFp16Field, DataType::VECTOR_FP16, kDimension, false, + diskann->clone())) + .ok()); + } + if (include_dynamic) { + EXPECT_TRUE( + schema + ->add_field(std::make_shared( + kDynamicField, DataType::VECTOR_FP32, kDimension, false)) + .ok()); + } + return schema; +} + +Doc MakeDoc(uint64_t doc_id, bool include_fp16 = false, + bool include_dynamic = false, bool include_group_by = false, + std::string pk = "") { + Doc doc; + doc.set_pk(pk.empty() ? "pk_" + std::to_string(doc_id) : std::move(pk)); + doc.set("category", static_cast(doc_id % 4)); + doc.set("name", "name_" + std::to_string(doc_id)); + doc.set>(kFp32Field, MakeFp32Vector(doc_id)); + if (include_group_by) { + doc.set>(kGroupByField, MakeFp32Vector(doc_id)); + } + if (include_fp16) { + doc.set>(kFp16Field, MakeFp16Vector(doc_id)); + } + if (include_dynamic) { + doc.set>(kDynamicField, MakeFp32Vector(doc_id + 7)); + } + return doc; +} + +std::vector MakeDocs(uint64_t begin, uint64_t end, + bool include_fp16 = false, + bool include_dynamic = false, + bool include_group_by = false) { + std::vector docs; + docs.reserve(end - begin); + for (uint64_t doc_id = begin; doc_id < end; ++doc_id) { + docs.emplace_back( + MakeDoc(doc_id, include_fp16, include_dynamic, include_group_by)); + } + return docs; +} + +SearchQuery MakeFp32Query(uint64_t doc_id, const std::string &field, + int topk = 5) { + auto vector = field == kDynamicField ? MakeFp32Vector(doc_id + 7) + : MakeFp32Vector(doc_id); + SearchQuery query; + query.topk_ = topk; + query.target_.field_name_ = field; + query.target_.query_params_ = std::make_shared(32); + query.target_.set_vector( + std::string(reinterpret_cast(vector.data()), + vector.size() * sizeof(float))); + return query; +} + +SearchQuery MakeFp16Query(uint64_t doc_id, int topk = 5) { + auto vector = MakeFp16Vector(doc_id); + SearchQuery query; + query.topk_ = topk; + query.target_.field_name_ = kFp16Field; + query.target_.query_params_ = std::make_shared(32); + query.target_.set_vector( + std::string(reinterpret_cast(vector.data()), + vector.size() * sizeof(float16_t))); + return query; +} + +SearchQuery MakeFlatQuery(uint64_t doc_id, int topk = 5) { + auto vector = MakeFp32Vector(doc_id); + SearchQuery query; + query.topk_ = topk; + query.target_.field_name_ = kGroupByField; + query.target_.query_params_ = std::make_shared(); + query.target_.set_vector( + std::string(reinterpret_cast(vector.data()), + vector.size() * sizeof(float))); + return query; +} + +std::vector SortedPks(const DocPtrList &docs) { + std::vector pks; + pks.reserve(docs.size()); + for (const auto &doc : docs) { + if (doc != nullptr) { + pks.emplace_back(doc->pk()); + } + } + std::sort(pks.begin(), pks.end()); + return pks; +} + +bool FetchContainsPk(const Result &result, const std::string &pk) { + if (!result.has_value() || result->size() != 1) { + return false; + } + auto it = result->find(pk); + return it != result->end() && it->second != nullptr; +} + +::testing::AssertionResult WriteSucceeded(const Result &result, + size_t expected_count) { + if (!result.has_value()) { + return ::testing::AssertionFailure() << result.error().message(); + } + if (result->size() != expected_count) { + return ::testing::AssertionFailure() + << "expected " << expected_count << " write results, got " + << result->size(); + } + for (size_t i = 0; i < result->size(); ++i) { + if (!result->at(i).ok()) { + return ::testing::AssertionFailure() + << "write " << i << " failed: " << result->at(i).message(); + } + } + return ::testing::AssertionSuccess(); +} + +class DiskAnnMobileCollectionTest : public ::testing::Test { + protected: + void SetUp() override { + ailego::FileHelper::RemoveDirectory(kCollectionPath); + } + + void TearDown() override { + ailego::FileHelper::RemoveDirectory(kCollectionPath); + } + + static CollectionOptions Options(bool read_only = false) { + return CollectionOptions{read_only, true, 32 * 1024 * 1024}; + } +}; + +TEST_F(DiskAnnMobileCollectionTest, PublicCollectionApiLifecycle) { + auto schema = MakeSchema(MetricType::L2, false, true); + auto options = Options(); + auto create_result = + Collection::CreateAndOpen(kCollectionPath, *schema, options); + ASSERT_TRUE(create_result.has_value()) << create_result.error().message(); + auto collection = std::move(create_result.value()); + + auto path_result = collection->path(); + ASSERT_TRUE(path_result.has_value()) << path_result.error().message(); + EXPECT_EQ(*path_result, kCollectionPath); + auto schema_result = collection->schema(); + ASSERT_TRUE(schema_result.has_value()) << schema_result.error().message(); + EXPECT_EQ(*schema_result, *schema); + auto options_result = collection->options(); + ASSERT_TRUE(options_result.has_value()) << options_result.error().message(); + EXPECT_EQ(*options_result, options); + auto empty_stats = collection->stats(); + ASSERT_TRUE(empty_stats.has_value()) << empty_stats.error().message(); + EXPECT_EQ(empty_stats->doc_count, 0u); + + auto docs = MakeDocs(0, 32, false, true); + ASSERT_TRUE(WriteSucceeded(collection->insert(docs), docs.size())); + ASSERT_TRUE(collection->flush().ok()); + auto flushed_stats = collection->stats(); + ASSERT_TRUE(flushed_stats.has_value()) << flushed_stats.error().message(); + ASSERT_EQ(flushed_stats->index_completeness[kFp32Field], 0); + ASSERT_TRUE(collection->optimize(OptimizeOptions{2}).ok()); + auto optimized_stats = collection->stats(); + ASSERT_TRUE(optimized_stats.has_value()) << optimized_stats.error().message(); + ASSERT_EQ(optimized_stats->index_completeness[kFp32Field], 1); + + auto fetch = collection->fetch( + {"pk_8"}, std::vector{"category", "name"}, false); + ASSERT_TRUE(fetch.has_value()) << fetch.error().message(); + ASSERT_TRUE(FetchContainsPk(fetch, "pk_8")); + EXPECT_TRUE(fetch->at("pk_8")->has("category")); + EXPECT_TRUE(fetch->at("pk_8")->has("name")); + EXPECT_FALSE(fetch->at("pk_8")->has(kFp32Field)); + + std::vector update_docs{MakeDoc(100, false, true, false, "pk_0")}; + ASSERT_TRUE( + WriteSucceeded(collection->update(update_docs), update_docs.size())); + + std::vector upsert_docs{MakeDoc(101, false, true, false, "pk_1"), + MakeDoc(32, false, true)}; + ASSERT_TRUE( + WriteSucceeded(collection->upsert(upsert_docs), upsert_docs.size())); + ASSERT_TRUE(WriteSucceeded(collection->delete_({"pk_2"}), 1)); + ASSERT_TRUE(collection->delete_by_filter("category = 3").ok()); + auto deleted_fetch = collection->fetch({"pk_2", "pk_3"}); + ASSERT_TRUE(deleted_fetch.has_value()) << deleted_fetch.error().message(); + ASSERT_EQ(deleted_fetch->size(), 2u); + auto deleted_pk2 = deleted_fetch->find("pk_2"); + auto deleted_pk3 = deleted_fetch->find("pk_3"); + ASSERT_NE(deleted_pk2, deleted_fetch->end()); + ASSERT_NE(deleted_pk3, deleted_fetch->end()); + EXPECT_EQ(deleted_pk2->second, nullptr); + EXPECT_EQ(deleted_pk3->second, nullptr); + + auto added_field = + std::make_shared("category_copy", DataType::INT32, false); + ASSERT_TRUE(collection->add_column(added_field, "category").ok()); + ASSERT_TRUE( + collection->alter_column("category_copy", "category_renamed").ok()); + ASSERT_TRUE(collection->drop_column("category_renamed").ok()); + + auto dynamic_index = + std::make_shared(MetricType::L2, 16, 32, 2); + ASSERT_TRUE(collection->create_index(kDynamicField, dynamic_index).ok()); + ASSERT_TRUE(collection->optimize(OptimizeOptions{2}).ok()); + auto dynamic_result = collection->query(MakeFp32Query(8, kDynamicField, 32)); + ASSERT_TRUE(dynamic_result.has_value()) << dynamic_result.error().message(); + ASSERT_FALSE(dynamic_result->empty()); + ASSERT_TRUE(collection->drop_index(kDynamicField).ok()); + + ASSERT_TRUE(collection->flush().ok()); + collection.reset(); + + auto reopen_result = Collection::Open(kCollectionPath, options); + ASSERT_TRUE(reopen_result.has_value()) << reopen_result.error().message(); + collection = std::move(reopen_result.value()); + auto primary_result = collection->query(MakeFp32Query(8, kFp32Field)); + ASSERT_TRUE(primary_result.has_value()) << primary_result.error().message(); + ASSERT_FALSE(primary_result->empty()); + auto reopened_stats = collection->stats(); + ASSERT_TRUE(reopened_stats.has_value()) << reopened_stats.error().message(); + EXPECT_LT(reopened_stats->doc_count, 33u); + collection.reset(); + + auto read_only_result = Collection::Open(kCollectionPath, Options(true)); + ASSERT_TRUE(read_only_result.has_value()) + << read_only_result.error().message(); + collection = std::move(read_only_result.value()); + auto read_only_query = collection->query(MakeFp32Query(8, kFp32Field)); + ASSERT_TRUE(read_only_query.has_value()) << read_only_query.error().message(); + ASSERT_FALSE(read_only_query->empty()); + auto rejected_docs = MakeDocs(40, 41, false, true); + EXPECT_FALSE(collection->insert(rejected_docs).has_value()); + EXPECT_FALSE(collection->optimize().ok()); + collection.reset(); + + reopen_result = Collection::Open(kCollectionPath, options); + ASSERT_TRUE(reopen_result.has_value()) << reopen_result.error().message(); + collection = std::move(reopen_result.value()); + ASSERT_TRUE(collection->destroy().ok()); + EXPECT_FALSE(collection->stats().has_value()); + EXPECT_FALSE(Collection::Open(kCollectionPath, options).has_value()); +} + +TEST_F(DiskAnnMobileCollectionTest, CompleteQuerySurfaceAndMetricMatrix) { + for (MetricType metric : + {MetricType::L2, MetricType::IP, MetricType::COSINE}) { + SCOPED_TRACE(static_cast(metric)); + ailego::FileHelper::RemoveDirectory(kCollectionPath); + auto schema = MakeSchema(metric, true, false, true); + auto create_result = + Collection::CreateAndOpen(kCollectionPath, *schema, Options()); + ASSERT_TRUE(create_result.has_value()) << create_result.error().message(); + auto collection = std::move(create_result.value()); + + auto docs = MakeDocs(0, kDocCount, true, false, true); + ASSERT_TRUE(WriteSucceeded(collection->insert(docs), docs.size())); + ASSERT_TRUE(collection->flush().ok()); + ASSERT_TRUE(collection->optimize(OptimizeOptions{2}).ok()); + + auto fp32_query = MakeFp32Query(12, kFp32Field, 8); + fp32_query.filter_ = "category = 0"; + fp32_query.include_vector_ = true; + fp32_query.include_doc_id_ = true; + fp32_query.output_fields_ = std::vector{"category", "name"}; + auto fp32_result = collection->query(fp32_query); + ASSERT_TRUE(fp32_result.has_value()) << fp32_result.error().message(); + ASSERT_FALSE(fp32_result->empty()); + for (const auto &doc : *fp32_result) { + ASSERT_NE(doc, nullptr); + auto category = doc->get("category"); + ASSERT_TRUE(category.has_value()); + EXPECT_EQ(category.value(), 0); + EXPECT_TRUE(doc->has("name")); + EXPECT_TRUE(doc->has(kFp32Field)); + } + EXPECT_TRUE(std::any_of(fp32_result->begin(), fp32_result->end(), + [](const Doc::Ptr &doc) { + return doc != nullptr && doc->doc_id() != 0; + })); + auto default_params_query = MakeFp32Query(12, kFp32Field); + default_params_query.target_.query_params_.reset(); + auto default_params_result = collection->query(default_params_query); + ASSERT_TRUE(default_params_result.has_value()) + << default_params_result.error().message(); + ASSERT_FALSE(default_params_result->empty()); + + SearchQuery scalar_query; + scalar_query.topk_ = 5; + scalar_query.filter_ = "category = 1"; + scalar_query.output_fields_ = std::vector{"category", "name"}; + auto scalar_result = collection->query(scalar_query); + ASSERT_TRUE(scalar_result.has_value()) << scalar_result.error().message(); + ASSERT_EQ(scalar_result->size(), 5u); + for (const auto &doc : *scalar_result) { + ASSERT_NE(doc, nullptr); + auto category = doc->get("category"); + ASSERT_TRUE(category.has_value()); + EXPECT_EQ(category.value(), 1); + } + + auto fp16_result = collection->query(MakeFp16Query(12, 8)); + ASSERT_TRUE(fp16_result.has_value()) << fp16_result.error().message(); + ASSERT_FALSE(fp16_result->empty()); + + ASSERT_GE(fp32_result->size(), 2u); + const float best_score = fp32_result->front()->score(); + const float worst_score = fp32_result->back()->score(); + const float radius = (best_score + worst_score) / 2.0F; + ASSERT_GT(radius, 0.0F); + auto radius_query = MakeFp32Query(12, kFp32Field, 8); + radius_query.filter_ = "category = 0"; + radius_query.target_.query_params_->set_radius(radius); + auto radius_result = collection->query(radius_query); + ASSERT_TRUE(radius_result.has_value()) << radius_result.error().message(); + ASSERT_FALSE(radius_result->empty()); + EXPECT_LT(radius_result->size(), fp32_result->size()); + for (const auto &doc : *radius_result) { + ASSERT_NE(doc, nullptr); + if (metric == MetricType::IP) { + EXPECT_GE(doc->score(), radius); + } else { + EXPECT_LE(doc->score(), radius); + } + } + + MultiQuery multi_query; + multi_query.topk = 8; + multi_query.filter = "category = 0"; + multi_query.include_vector = true; + multi_query.include_doc_id_ = true; + multi_query.output_fields = std::vector{"category", "name"}; + multi_query.rerank = reranker::RrfParams{60}; + for (uint64_t doc_id : {12u, 20u}) { + auto search_query = MakeFp32Query(doc_id, kFp32Field, 16); + SubQuery sub_query; + sub_query.target_ = std::move(search_query.target_); + sub_query.num_candidates_ = 16; + multi_query.queries.emplace_back(std::move(sub_query)); + } + auto multi_result = collection->query(multi_query); + ASSERT_TRUE(multi_result.has_value()) << multi_result.error().message(); + ASSERT_FALSE(multi_result->empty()); + EXPECT_LE(multi_result->size(), 8u); + for (const auto &doc : *multi_result) { + ASSERT_NE(doc, nullptr); + EXPECT_TRUE(doc->has("category")); + EXPECT_TRUE(doc->has("name")); + EXPECT_TRUE(doc->has(kFp32Field)); + auto category = doc->get("category"); + ASSERT_TRUE(category.has_value()); + EXPECT_EQ(category.value(), 0); + } + EXPECT_TRUE(std::any_of(multi_result->begin(), multi_result->end(), + [](const Doc::Ptr &doc) { + return doc != nullptr && doc->doc_id() != 0; + })); + + GroupByVectorQuery group_query; + group_query.target_ = MakeFlatQuery(12, 8).target_; + group_query.filter_ = "category >= 0"; + group_query.group_by_field_name_ = "category"; + group_query.group_count_ = 4; + group_query.topk_per_group_ = 2; + group_query.include_vector_ = true; + group_query.output_fields_ = std::vector{"category", "name"}; + auto group_result = collection->group_by_query(group_query); + ASSERT_TRUE(group_result.has_value()) << group_result.error().message(); + ASSERT_FALSE(group_result->empty()); + EXPECT_LE(group_result->size(), 4u); + for (const auto &group : *group_result) { + EXPECT_FALSE(group.group_by_value_.empty()); + EXPECT_FALSE(group.docs_.empty()); + EXPECT_LE(group.docs_.size(), 2u); + for (const auto &doc : group.docs_) { + EXPECT_TRUE(doc.has("category")); + EXPECT_TRUE(doc.has("name")); + EXPECT_TRUE(doc.has(kGroupByField)); + } + } + + auto selected_fetch = collection->fetch( + {"pk_12"}, std::vector{"category"}, false); + ASSERT_TRUE(selected_fetch.has_value()) << selected_fetch.error().message(); + ASSERT_TRUE(FetchContainsPk(selected_fetch, "pk_12")); + EXPECT_TRUE(selected_fetch->at("pk_12")->has("category")); + EXPECT_FALSE(selected_fetch->at("pk_12")->has("name")); + EXPECT_FALSE(selected_fetch->at("pk_12")->has(kFp32Field)); + + collection.reset(); + auto reopen_result = Collection::Open(kCollectionPath, Options()); + ASSERT_TRUE(reopen_result.has_value()) << reopen_result.error().message(); + collection = std::move(reopen_result.value()); + auto reopened_query = collection->query(MakeFp32Query(12, kFp32Field)); + ASSERT_TRUE(reopened_query.has_value()) << reopened_query.error().message(); + ASSERT_FALSE(reopened_query->empty()); + } +} + +TEST_F(DiskAnnMobileCollectionTest, ConcurrentQueryAndFetch) { + constexpr size_t kThreadCount = 4; + constexpr size_t kIterations = 20; + + auto schema = MakeSchema(MetricType::L2); + auto create_result = + Collection::CreateAndOpen(kCollectionPath, *schema, Options()); + ASSERT_TRUE(create_result.has_value()) << create_result.error().message(); + auto collection = std::move(create_result.value()); + auto docs = MakeDocs(0, kDocCount); + ASSERT_TRUE(WriteSucceeded(collection->insert(docs), docs.size())); + ASSERT_TRUE(collection->optimize(OptimizeOptions{2}).ok()); + + std::array, kDocCount> query_baselines; + for (uint64_t doc_id = 0; doc_id < kDocCount; ++doc_id) { + auto query_result = collection->query(MakeFp32Query(doc_id, kFp32Field)); + ASSERT_TRUE(query_result.has_value()) << query_result.error().message(); + ASSERT_FALSE(query_result->empty()); + query_baselines[doc_id] = SortedPks(*query_result); + ASSERT_EQ(query_baselines[doc_id].size(), query_result->size()); + } + + std::atomic failure_count{0}; + std::mutex failure_mutex; + std::vector failures; + auto record_failure = [&](const std::string &failure) { + ++failure_count; + std::lock_guard lock(failure_mutex); + failures.emplace_back(failure); + }; + std::vector threads; + threads.reserve(kThreadCount); + for (size_t thread_id = 0; thread_id < kThreadCount; ++thread_id) { + threads.emplace_back([&, thread_id]() { + for (size_t iteration = 0; iteration < kIterations; ++iteration) { + uint64_t doc_id = (thread_id * kIterations + iteration) % kDocCount; + auto query_result = + collection->query(MakeFp32Query(doc_id, kFp32Field)); + auto fetch_result = collection->fetch({"pk_" + std::to_string(doc_id)}); + bool query_ok = query_result.has_value() && + SortedPks(*query_result) == query_baselines[doc_id]; + bool fetch_ok = + FetchContainsPk(fetch_result, "pk_" + std::to_string(doc_id)); + if (!query_ok || !fetch_ok) { + record_failure( + "shared collection: thread=" + std::to_string(thread_id) + + ", iteration=" + std::to_string(iteration) + + ", query_ok=" + std::to_string(query_ok) + + ", fetch_ok=" + std::to_string(fetch_ok)); + } + } + }); + } + for (auto &thread : threads) { + thread.join(); + } + + EXPECT_EQ(failure_count.load(), 0u); + EXPECT_TRUE(failures.empty()) << (failures.empty() ? "" : failures.front()); + + ASSERT_TRUE(collection->flush().ok()); + collection.reset(); + failure_count.store(0); + failures.clear(); + threads.clear(); + for (size_t thread_id = 0; thread_id < kThreadCount; ++thread_id) { + threads.emplace_back([&, thread_id]() { + auto open_result = Collection::Open(kCollectionPath, Options(true)); + if (!open_result.has_value()) { + record_failure("read-only open: thread=" + std::to_string(thread_id)); + return; + } + auto read_only_collection = std::move(open_result.value()); + for (size_t iteration = 0; iteration < kIterations; ++iteration) { + uint64_t doc_id = (thread_id * kIterations + iteration) % kDocCount; + auto query_result = + read_only_collection->query(MakeFp32Query(doc_id, kFp32Field)); + auto fetch_result = + read_only_collection->fetch({"pk_" + std::to_string(doc_id)}); + bool query_ok = query_result.has_value() && + SortedPks(*query_result) == query_baselines[doc_id]; + bool fetch_ok = + FetchContainsPk(fetch_result, "pk_" + std::to_string(doc_id)); + if (!query_ok || !fetch_ok) { + record_failure( + "read-only collection: thread=" + std::to_string(thread_id) + + ", iteration=" + std::to_string(iteration) + + ", query_ok=" + std::to_string(query_ok) + + ", fetch_ok=" + std::to_string(fetch_ok)); + } + } + }); + } + for (auto &thread : threads) { + thread.join(); + } + + EXPECT_EQ(failure_count.load(), 0u); + EXPECT_TRUE(failures.empty()) << (failures.empty() ? "" : failures.front()); +} + +TEST_F(DiskAnnMobileCollectionTest, OperationFailuresDoNotPoisonCollection) { + auto schema = MakeSchema(MetricType::L2); + auto create_result = + Collection::CreateAndOpen(kCollectionPath, *schema, Options()); + ASSERT_TRUE(create_result.has_value()) << create_result.error().message(); + auto collection = std::move(create_result.value()); + auto docs = MakeDocs(0, kDocCount); + ASSERT_TRUE(WriteSucceeded(collection->insert(docs), docs.size())); + ASSERT_TRUE(collection->optimize(OptimizeOptions{2}).ok()); + + auto invalid_query = MakeFp32Query(12, kFp32Field); + invalid_query.target_.set_vector("invalid-size"); + EXPECT_FALSE(collection->query(invalid_query).has_value()); + + auto wrong_params_query = MakeFp32Query(12, kFp32Field); + wrong_params_query.target_.query_params_ = + std::make_shared(); + EXPECT_FALSE(collection->query(wrong_params_query).has_value()); + + Doc invalid_doc; + invalid_doc.set_pk("invalid_doc"); + invalid_doc.set("category", 0); + invalid_doc.set("name", "missing required vector"); + std::vector invalid_docs{invalid_doc}; + auto invalid_write = collection->insert(invalid_docs); + EXPECT_TRUE(!invalid_write.has_value() || invalid_write->empty() || + !invalid_write->front().ok()); + + GroupByVectorQuery unsupported_group_query; + unsupported_group_query.target_ = MakeFp32Query(12, kFp32Field, 8).target_; + unsupported_group_query.group_by_field_name_ = "category"; + unsupported_group_query.group_count_ = 4; + unsupported_group_query.topk_per_group_ = 2; + EXPECT_FALSE(collection->group_by_query(unsupported_group_query).has_value()); + + auto valid_result = collection->query(MakeFp32Query(12, kFp32Field)); + ASSERT_TRUE(valid_result.has_value()) << valid_result.error().message(); + ASSERT_FALSE(valid_result->empty()); + + auto recovery_docs = MakeDocs(kDocCount, kDocCount + 1); + ASSERT_TRUE( + WriteSucceeded(collection->insert(recovery_docs), recovery_docs.size())); + ASSERT_TRUE(collection->flush().ok()); + ASSERT_TRUE(collection->optimize(OptimizeOptions{2}).ok()); + collection.reset(); + + auto reopen_result = Collection::Open(kCollectionPath, Options()); + ASSERT_TRUE(reopen_result.has_value()) << reopen_result.error().message(); + collection = std::move(reopen_result.value()); + auto recovered_result = + collection->query(MakeFp32Query(kDocCount, kFp32Field)); + ASSERT_TRUE(recovered_result.has_value()) + << recovered_result.error().message(); + ASSERT_FALSE(recovered_result->empty()); + const std::string recovered_pk = "pk_" + std::to_string(kDocCount); + auto recovered_fetch = collection->fetch({recovered_pk}); + EXPECT_TRUE(FetchContainsPk(recovered_fetch, recovered_pk)); + auto recovered_stats = collection->stats(); + ASSERT_TRUE(recovered_stats.has_value()) << recovered_stats.error().message(); + EXPECT_EQ(recovered_stats->doc_count, kDocCount + 1); +} + +#else + +TEST(DiskAnnMobileCollectionTest, PlatformDoesNotClaimMobileSupport) { + GTEST_SKIP() << "DiskAnn is not enabled on this desktop platform"; +} + +#endif + +} // namespace +} // namespace zvec From d490c6e16524415dccbc00d82f43b3d84fa73a6c Mon Sep 17 00:00:00 2001 From: Zefeng Yin Date: Fri, 28 Aug 2026 16:01:00 +0800 Subject: [PATCH 3/5] test(diskann): stabilize mobile radius assertions --- tests/db/diskann_mobile_collection_test.cc | 42 ++++++++++++++++------ 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/tests/db/diskann_mobile_collection_test.cc b/tests/db/diskann_mobile_collection_test.cc index 9db80bab1..2d429a78c 100644 --- a/tests/db/diskann_mobile_collection_test.cc +++ b/tests/db/diskann_mobile_collection_test.cc @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -419,25 +420,44 @@ TEST_F(DiskAnnMobileCollectionTest, CompleteQuerySurfaceAndMetricMatrix) { ASSERT_TRUE(fp16_result.has_value()) << fp16_result.error().message(); ASSERT_FALSE(fp16_result->empty()); - ASSERT_GE(fp32_result->size(), 2u); - const float best_score = fp32_result->front()->score(); - const float worst_score = fp32_result->back()->score(); - const float radius = (best_score + worst_score) / 2.0F; - ASSERT_GT(radius, 0.0F); + // Use the exhaustive DiskANN path for both sides of radius validation. + // ANN searches may produce different candidate sets, and result order is + // not a suitable substitute for computing the actual score extrema. + auto radius_baseline_query = MakeFp32Query(12, kFp32Field, 8); + radius_baseline_query.filter_ = "category = 0"; + radius_baseline_query.target_.query_params_->set_is_linear(true); + auto radius_baseline_result = collection->query(radius_baseline_query); + ASSERT_TRUE(radius_baseline_result.has_value()) + << radius_baseline_result.error().message(); + ASSERT_GE(radius_baseline_result->size(), 2u); + std::vector radius_baseline_scores; + radius_baseline_scores.reserve(radius_baseline_result->size()); + for (const auto &doc : *radius_baseline_result) { + ASSERT_NE(doc, nullptr); + ASSERT_TRUE(std::isfinite(doc->score())); + radius_baseline_scores.emplace_back(doc->score()); + } + const auto [min_score, max_score] = std::minmax_element( + radius_baseline_scores.begin(), radius_baseline_scores.end()); + float radius = (*min_score + *max_score) / 2.0F; + if (radius <= 0.0F) { + radius = 0.001F; + } + const auto is_within_radius = [metric, radius](float score) { + return metric == MetricType::IP ? score >= radius : score <= radius; + }; + ASSERT_TRUE(std::any_of(radius_baseline_scores.begin(), + radius_baseline_scores.end(), is_within_radius)); auto radius_query = MakeFp32Query(12, kFp32Field, 8); radius_query.filter_ = "category = 0"; radius_query.target_.query_params_->set_radius(radius); + radius_query.target_.query_params_->set_is_linear(true); auto radius_result = collection->query(radius_query); ASSERT_TRUE(radius_result.has_value()) << radius_result.error().message(); ASSERT_FALSE(radius_result->empty()); - EXPECT_LT(radius_result->size(), fp32_result->size()); for (const auto &doc : *radius_result) { ASSERT_NE(doc, nullptr); - if (metric == MetricType::IP) { - EXPECT_GE(doc->score(), radius); - } else { - EXPECT_LE(doc->score(), radius); - } + EXPECT_TRUE(is_within_radius(doc->score())); } MultiQuery multi_query; From ec203090ff4da70f3255adba47b3c990543a07ae Mon Sep 17 00:00:00 2001 From: Zefeng Yin Date: Fri, 28 Aug 2026 17:29:09 +0800 Subject: [PATCH 4/5] fix(build): avoid duplicate Android whole-archive links --- cmake/bazel.cmake | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/cmake/bazel.cmake b/cmake/bazel.cmake index d3375048d..9750bfaae 100644 --- a/cmake/bazel.cmake +++ b/cmake/bazel.cmake @@ -786,7 +786,16 @@ function(_target_link_libraries _NAME) endif() if(NOT MSVC) - if(NOT ${CMAKE_SYSTEM_NAME} MATCHES "Darwin" AND NOT ${CMAKE_SYSTEM_NAME} MATCHES "iOS") + if(ANDROID AND ANDROID_STL STREQUAL "c++_static") + # Keep the target in the normal link graph so CMake can resolve its + # transitive dependencies, but force-load the archive exactly once via + # a link option. Wrapping the target directly in --whole-archive here + # lets CMake emit another transitive occurrence later; lld then loads + # the same objects twice and reports duplicate symbols. + list(APPEND LINK_LIBS ${LIB}) + list(APPEND ANDROID_WHOLEARCHIVE_OPTS + -Wl,--whole-archive,$,--no-whole-archive) + elseif(NOT ${CMAKE_SYSTEM_NAME} MATCHES "Darwin" AND NOT ${CMAKE_SYSTEM_NAME} MATCHES "iOS") list(APPEND LINK_LIBS -Wl,--whole-archive ${LIB} -Wl,--no-whole-archive) else() list(APPEND LINK_LIBS -Wl,-force_load ${LIB}) @@ -812,6 +821,9 @@ function(_target_link_libraries _NAME) endforeach() target_link_libraries(${_NAME} ${LINK_LIBS}) + if(ANDROID_WHOLEARCHIVE_OPTS) + target_link_options(${_NAME} PRIVATE ${ANDROID_WHOLEARCHIVE_OPTS}) + endif() if(MSVC_WHOLEARCHIVE_OPTS) target_link_options(${_NAME} PRIVATE ${MSVC_WHOLEARCHIVE_OPTS}) endif() From a2a82cb5076a1bf5efc39288e186944b2e88e781 Mon Sep 17 00:00:00 2001 From: Zefeng Yin Date: Fri, 28 Aug 2026 18:25:01 +0800 Subject: [PATCH 5/5] fix(index): preserve transformed query lifetime --- src/core/interface/index.cc | 11 +++++++---- tests/db/diskann_mobile_collection_test.cc | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/core/interface/index.cc b/src/core/interface/index.cc index 557871fc0..4c3cd41aa 100644 --- a/src/core/interface/index.cc +++ b/src/core/interface/index.cc @@ -772,16 +772,19 @@ int Index::_dense_search(const VectorData &vector_data, } const DenseVector &dense_vector = std::get(vector_data.vector); auto vector = dense_vector.data; + std::string transformed_vector; // Check if need to transform feature core::IndexQueryMeta new_meta = input_vector_meta_; if (reformer_ != nullptr) { - auto *new_vector = context->mutable_features(); - if (reformer_->transform(dense_vector.data, input_vector_meta_, new_vector, - &new_meta) != 0) { + if (reformer_->transform(dense_vector.data, input_vector_meta_, + &transformed_vector, &new_meta) != 0) { LOG_ERROR("Failed to transform vector"); return core::IndexError_Runtime; } - vector = new_vector->data(); + // A streamer may replace an incompatible pooled context before searching. + // Keep the transformed query independent from that context so its data + // remains valid for the complete search call. + vector = transformed_vector.data(); } if (search_param->bf_pks != nullptr) { // should we eliminate the copy of bf_pks? diff --git a/tests/db/diskann_mobile_collection_test.cc b/tests/db/diskann_mobile_collection_test.cc index 2d429a78c..c7bb76acb 100644 --- a/tests/db/diskann_mobile_collection_test.cc +++ b/tests/db/diskann_mobile_collection_test.cc @@ -385,6 +385,7 @@ TEST_F(DiskAnnMobileCollectionTest, CompleteQuerySurfaceAndMetricMatrix) { ASSERT_FALSE(fp32_result->empty()); for (const auto &doc : *fp32_result) { ASSERT_NE(doc, nullptr); + ASSERT_TRUE(std::isfinite(doc->score())); auto category = doc->get("category"); ASSERT_TRUE(category.has_value()); EXPECT_EQ(category.value(), 0); @@ -457,6 +458,7 @@ TEST_F(DiskAnnMobileCollectionTest, CompleteQuerySurfaceAndMetricMatrix) { ASSERT_FALSE(radius_result->empty()); for (const auto &doc : *radius_result) { ASSERT_NE(doc, nullptr); + ASSERT_TRUE(std::isfinite(doc->score())); EXPECT_TRUE(is_within_radius(doc->score())); }