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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions include/rabitqlib/core/detail/rabitq_impl.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,9 @@ inline T ex_bits_code_with_norm(
ConstRowMajorArrayMap<T> res_arr(residual, 1, dim);
T ipnorm_inv = 1;
if (residual_norm == 0) {
// Match code_with_factor_from_residual(): no magnitude bits, so skip the revert.
std::fill(ex_code, ex_code + dim, static_cast<TP>(0));
return ipnorm_inv;
} else {
RowMajorArray<T> owned_absolute;
auto abs_res = scratch_array(
Expand Down
6 changes: 6 additions & 0 deletions src/index/hnsw.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ HierarchicalNSW::HierarchicalNSW(
}

void HierarchicalNSW::save(const char* filename) const {
if (level0_record_bytes_ == 0) {
throw std::logic_error("HNSW index must be initialized before save");
}
std::ofstream output(rabitqlib::io_impl::filesystem_path(filename), std::ios::binary);
if (!output.is_open()) {
throw std::runtime_error("HNSW: cannot open index file for writing");
Expand Down Expand Up @@ -957,6 +960,9 @@ PID HierarchicalNSW::mutually_connect_new_element(
std::vector<std::vector<std::pair<float, PID>>> HierarchicalNSW::search(
const float* queries, size_t num_queries, size_t k, size_t ef_search, size_t num_threads
) {
if (level0_record_bytes_ == 0) {
throw std::logic_error("HNSW index must be initialized before search");
}
if (k == 0) {
throw std::invalid_argument("HNSW k must be greater than zero");
}
Expand Down
2 changes: 1 addition & 1 deletion src/index/pipnn.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@ InitialGraph build_initial_graph(
) {
validate_metric_type(metric_type);
if (data == nullptr || dim == 0 || degree == 0 || degree >= count ||
count >= buffer::kSearchBufferMaxPointCount || degree % 32 != 0) {
count > buffer::kSearchBufferMaxPointCount || degree % 32 != 0) {
throw std::invalid_argument(
"PiPNN seed requires data, positive dim, and a degree multiple of 32 below "
"count"
Expand Down
2 changes: 1 addition & 1 deletion src/simd/rotator_kernels.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ void fht_rotate_impl(
}
#undef RABITQ_FHT_CASE

std::memcpy(rotated_data, data, sizeof(float) * dim);
std::memmove(rotated_data, data, sizeof(float) * dim);
std::fill(rotated_data + dim, rotated_data + padded_dim, 0.0F);

if (trunc_dim == padded_dim) {
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/rabitqlib/index/hnsw_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,29 @@ TEST(HnswResizeTest, RejectsUninitializedIndex) {
}
}

TEST(HnswUninitializedTest, RejectsSearchAndSave) {
HierarchicalNSW index;
const std::vector<float> query(64, 1.0F);
for (const size_t num_queries : {1U, 0U}) {
SCOPED_TRACE(num_queries);
try {
index.search(query.data(), num_queries, 1, 10, 1);
FAIL() << "Searching an uninitialized index must fail";
} catch (const std::logic_error& error) {
EXPECT_STREQ(error.what(), "HNSW index must be initialized before search");
}
}

const rabitq_test::TempFile path_file("rabitq_hnsw_uninitialized_save.index");
try {
index.save(path_file.path().c_str());
FAIL() << "Saving an uninitialized index must fail";
} catch (const std::logic_error& error) {
EXPECT_STREQ(error.what(), "HNSW index must be initialized before save");
}
EXPECT_FALSE(std::filesystem::exists(std::filesystem::u8path(path_file.path())));
}

TEST(HnswResizeTest, AllowsResizeBeforeConstruction) {
constexpr size_t kBuilt = 64;
constexpr size_t kCapacity = 128;
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/rabitqlib/quantization/xy_quantization_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,55 @@ TEST(XyQuantization, SignBitMatchesOneBitPathOnZeroResiduals) {
}
}

// A residual whose squared norm is zero takes the zero-residual path of the other
// encoders; the split code must reassemble to the same full code. The second case
// has nonzero components whose float squares underflow to zero.
TEST(XyQuantization, ZeroNormResidualMatchesFullCode) {
constexpr size_t kDim = 64;
constexpr size_t kExtraBits = 3;

std::vector<float> data(kDim, 1.0F);
std::vector<float> tiny(kDim, 0.0F);
for (size_t j = 0; j < kDim; j += 2) {
tiny[j] = 1e-30F;
}
ASSERT_EQ(l2norm_sqr<float>(tiny.data(), kDim), 0.0F);
const std::vector<std::pair<std::vector<float>, std::vector<float>>> cases{
{data, data}, {tiny, std::vector<float>(kDim, 0.0F)}};

for (const auto& [input, centroid] : cases) {
for (size_t base_bits : {1U, 2U}) {
for (MetricType metric : {METRIC_L2, METRIC_IP}) {
SCOPED_TRACE(base_bits);
SCOPED_TRACE(metric);
XySplitResult res =
SplitCode(input, centroid, kDim, base_bits, kExtraBits, metric);

std::vector<uint16_t> full_code(kDim);
float full_add = 0, full_rescale = 0, full_error = 0;
quant::quantize_full_single(
input.data(),
centroid.data(),
kDim,
base_bits + kExtraBits,
full_code.data(),
full_add,
full_rescale,
full_error,
metric
);
for (size_t j = 0; j < kDim; ++j) {
const uint32_t total =
(static_cast<uint32_t>(res.base_code[j]) << kExtraBits) |
static_cast<uint32_t>(res.extra_code[j]);
EXPECT_EQ(total, full_code[j]) << "dim " << j;
}
EXPECT_EQ(res.f_rescale_full, 0.0F);
}
}
}
}

TEST(XyQuantization, BaseBitsOneMatchesExistingFactorsOnZeroResidualComponents) {
constexpr size_t kDim = 64;
constexpr size_t kExtraBits = 3;
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/rabitqlib/utils/rotator_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,68 @@ TEST(FhtDispatchTest, BackendsMatchScalarButterfliesAndPadding) {
}
}

// Rotators allow in-place calls, so every backend must accept an output that
// overlaps its input. Run under ASan to catch overlapping memcpy.
TEST(FhtDispatchTest, BackendsAcceptOverlappingInputAndOutput) {
std::vector<decltype(&simd::fht_rotate)> backends{
simd::fht_rotate_generic, simd::fht_rotate};
#if defined(__x86_64__) || defined(_M_X64)
if (cpu::has_avx2()) {
backends.push_back(simd::fht_rotate_avx2);
}
if (cpu::has_avx512_core()) {
backends.push_back(simd::fht_rotate_avx512);
}
#endif
#if defined(__aarch64__) || defined(_M_ARM64)
if (cpu::has_neon()) {
backends.push_back(simd::fht_rotate_neon);
}
#endif
for (size_t dim : {64U, 65U, 127U, 128U, 193U, 960U, 4097U}) {
SCOPED_TRACE(dim);
const size_t padded = (dim + 31) / 32 * 32;
size_t trunc = 64;
while (trunc * 2 <= dim) {
trunc *= 2;
}
const float fac = 1.0F / std::sqrt(static_cast<float>(trunc));
std::vector<uint8_t> flip(padded / 2);
for (size_t i = 0; i < flip.size(); ++i) {
flip[i] = static_cast<uint8_t>(i * 73 + i / 7);
}
std::vector<float> input(dim);
for (size_t i = 0; i < dim; ++i) {
input[i] = std::sin(static_cast<float>(i) * 0.13F);
}
for (auto backend : backends) {
std::vector<float> expected(padded);
backend(input.data(), expected.data(), dim, padded, trunc, fac, flip.data());
for (size_t offset : {0U, 1U}) {
SCOPED_TRACE(offset);
std::vector<float> actual(padded + 2, 12345);
std::copy(input.begin(), input.end(), actual.begin() + 1);
backend(
actual.data() + 1,
actual.data() + offset,
dim,
padded,
trunc,
fac,
flip.data()
);
EXPECT_EQ(
std::memcmp(
actual.data() + offset, expected.data(), padded * sizeof(float)
),
0
);
EXPECT_EQ(actual.back(), 12345);
}
}
}
}

TEST(FhtDispatchTest, PreservesZeroNormAndInnerProduct) {
std::vector<decltype(&simd::fht_rotate)> backends{
simd::fht_rotate_generic, simd::fht_rotate};
Expand Down
Loading