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
6 changes: 3 additions & 3 deletions docs/docs/index/hnsw.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ void HierarchicalNSW::construct(
size_t num_points,
const float* data,
PID* cluster_ids,
size_t num_threads = 0,
size_t num_threads = std::numeric_limits<size_t>::max(),
bool fast_quantization = false
);
```
Expand All @@ -103,7 +103,7 @@ void HierarchicalNSW::construct(
- **num_points**: The number of data vectors.
- **centroids**: Centroids computed by [RaBitQKMeans](../clustering.md#rabitqkmeans); the examples use `num_clusters = 16`.
- **cluster_ids**: Array of length `num_points`; every entry must be in the range `[0, num_clusters)`.
- **num_threads**: Number of threads to use (default: 0, which auto-selects).
- **num_threads**: Number of threads to use (default: every available thread; `0` also auto-selects).
- **fast_quantization**: Use calibrated scaling instead of per-vector scale search; see [encoding paths](../rabitq/quantizer.md#implementations).

## Querying
Expand Down Expand Up @@ -153,7 +153,7 @@ void HierarchicalNSW::resize(size_t new_max_elements);
is `nullptr`, each vector goes to its nearest centroid, chosen the same way a
query is routed.
- **fast_quantization**, **num_threads**: Same meaning as in `construct`, but `add` defaults
to one thread; pass `0` to auto-select. A point keeps the label
to one thread, which keeps the resulting links deterministic; pass `0` to auto-select. A point keeps the label
`num_stored_points() + i` whatever order the threads finish in, because the slots are
reserved as one block before the inserts start.
- **new_max_elements**: The new capacity. It must be at least `num_stored_points()`.
Expand Down
6 changes: 6 additions & 0 deletions docs/docs/index/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ bytes, but non-UTF-8 filenames require filesystem support. Windows byte paths us
UTF-8. Embedded null characters are rejected before file access. IVF HNSW sidecars
use the same path conversion.

`save` writes a temporary file beside the destination and renames it into place, so a
failed save leaves an existing index unchanged. A replaced file keeps its permission
bits, but not its owner or other hard links. Symbolic links and other non-regular
destinations are written in place. IVF renames its HNSW sidecar before the index file;
a failure between the two renames can leave a new sidecar beside the old index.

## Index file compatibility

The compatibility suite loads frozen files written by v0.5.2, compares search
Expand Down
4 changes: 2 additions & 2 deletions include/rabitqlib/core/rotator.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ namespace rabitqlib {
template <typename T>
class Rotator {
protected:
size_t dim_;
size_t padded_dim_;
size_t dim_ = 0;
size_t padded_dim_ = 0;

public:
explicit Rotator() = default;
Expand Down
10 changes: 8 additions & 2 deletions include/rabitqlib/index/hnsw/hnsw.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <cstdint>
#include <cstring>
#include <functional>
#include <limits>
#include <memory>
#include <mutex>
#include <queue>
Expand Down Expand Up @@ -89,7 +90,7 @@ class HierarchicalNSW {
size_t num_points,
const float* data,
PID* cluster_ids,
size_t num_threads = 0,
size_t num_threads = std::numeric_limits<size_t>::max(),
bool fast_quantization = false
);

Expand All @@ -110,7 +111,9 @@ class HierarchicalNSW {
* route each one to its nearest centroid
* @param fast_quantization Use fixed-scale extra-bit quantization for speed at a
* possible loss of accuracy; has no effect on one-bit storage
* @param num_threads Threads used to route and insert
* @param num_threads Threads used to route and insert. Labels never depend on it,
* but links do with more than one thread, so the default of one keeps the graph
* deterministic.
* @return The label given to each new point
*/
std::vector<PID> add(
Expand Down Expand Up @@ -362,6 +365,9 @@ class HierarchicalNSW {

int get_random_level(double reverse_size);

// Write the index file at exactly this path.
void write_file(const char* filename) const;

PID* get_linklist(PID internal_id, int level) {
return reinterpret_cast<PID*>(
link_lists_[internal_id].get() + ((level - 1) * upper_layer_links_bytes_)
Expand Down
3 changes: 3 additions & 0 deletions include/rabitqlib/index/ivf/ivf.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ class IVF {

inline void validate_search(const float*, size_t, size_t, const PID*) const;

// Write the index file, and any initializer sidecar, at exactly this path.
void write_file(const char* filename) const;

public:
explicit IVF() noexcept;
// dim is the original input dimension; total_bits is 1-9 for quantized
Expand Down
3 changes: 3 additions & 0 deletions include/rabitqlib/index/symqg/qg.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ class QuantizedGraph<float> {

void initialize(bool randomize_rotation = true);

// Write the graph file at exactly this path.
void write_file(const char* filename) const;

void copy_vectors(const float*, size_t);

void set_quantization_centroid(const float* centroid);
Expand Down
99 changes: 99 additions & 0 deletions src/index/file_replacement.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#pragma once

#include <filesystem>
#include <initializer_list>
#include <random>
#include <string>
#include <system_error>

#include "rabitqlib/utils/path.hpp"

namespace rabitqlib::index_detail {

// Only a missing or regular destination can be replaced by renaming a sibling onto it.
// Devices, FIFOs, directories and symbolic links keep being written through in place.
inline bool replaceable_by_rename(const std::string& filename) {
std::error_code error;
const auto type =
std::filesystem::symlink_status(io_impl::filesystem_path(filename), error).type();
return type == std::filesystem::file_type::not_found ||
type == std::filesystem::file_type::regular;
}

// Give a replacement the permission bits of the regular file it replaces. Ownership and
// hard links are not carried over; a file system that cannot change permissions keeps
// the replacement's defaults rather than failing the save.
inline void copy_permissions(const std::string& existing, const std::string& replacement) {
std::error_code error;
const auto status = std::filesystem::status(io_impl::filesystem_path(existing), error);
if (error || status.type() != std::filesystem::file_type::regular) {
return;
}
std::filesystem::permissions(
io_impl::filesystem_path(replacement),
status.permissions(),
std::filesystem::perm_options::replace,
error
);
}

/**
* Save through a temporary sibling that replaces `filename` only after `write` succeeds,
* so a failed save leaves any existing file unchanged and removes what it wrote.
* A replaced file keeps its permission bits, but not its owner or other hard links.
*
* `write(path)` writes the main file at `path` and each sidecar at `path + suffix`.
* Sidecars are renamed into place before the main file. If any destination exists but
* is not a regular file, every file is written in place instead, as before.
*/
template <class Write>
void save_replacing(
const std::string& filename,
std::initializer_list<const char*> sidecar_suffixes,
Write write
) {
bool in_place = !replaceable_by_rename(filename);
for (const char* suffix : sidecar_suffixes) {
in_place = in_place || !replaceable_by_rename(filename + suffix);
}
if (in_place) {
write(filename);
return;
}

// A random name keeps concurrent saves to one destination from sharing a temporary.
std::random_device random;
const unsigned long long token =
(static_cast<unsigned long long>(random()) << 32U) ^ random();
const std::string temporary = filename + ".tmp" + std::to_string(token);
try {
write(temporary);
copy_permissions(filename, temporary);
for (const char* suffix : sidecar_suffixes) {
copy_permissions(filename + suffix, temporary + suffix);
}
for (const char* suffix : sidecar_suffixes) {
std::filesystem::rename(
io_impl::filesystem_path(temporary + suffix),
io_impl::filesystem_path(filename + suffix)
);
}
std::filesystem::rename(
io_impl::filesystem_path(temporary), io_impl::filesystem_path(filename)
);
} catch (...) {
try {
std::error_code ignored;
std::filesystem::remove(io_impl::filesystem_path(temporary), ignored);
for (const char* suffix : sidecar_suffixes) {
std::filesystem::remove(
io_impl::filesystem_path(temporary + suffix), ignored
);
}
} catch (...) { // NOLINT(bugprone-empty-catch): report the save failure instead.
}
throw;
}
}

} // namespace rabitqlib::index_detail
19 changes: 16 additions & 3 deletions src/index/hnsw.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
#include <queue>
#include <random>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>

#include "file_replacement.hpp"
#include "rabitqlib/core/data_layout.hpp"
#include "rabitqlib/core/encoder.hpp"
#include "rabitqlib/core/estimator.hpp"
Expand Down Expand Up @@ -153,13 +155,24 @@ void HierarchicalNSW::save(const char* filename) const {
if (level0_record_bytes_ == 0) {
throw std::logic_error("HNSW index must be initialized before save");
}
if (filename == nullptr || filename[0] == '\0') {
throw std::invalid_argument("HNSW: index filename must not be empty");
}
index_detail::save_replacing(filename, {}, [this](const std::string& path) {
write_file(path.c_str());
});
}

void HierarchicalNSW::write_file(const char* filename) const {
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");
}

// Serialize one count value rather than the bytes of the atomic object.
const size_t num_stored_points = num_stored_points_.load();
output.write(reinterpret_cast<const char*>(&max_elements_), sizeof(size_t));
output.write(reinterpret_cast<const char*>(&num_stored_points_), sizeof(size_t));
output.write(reinterpret_cast<const char*>(&num_stored_points), sizeof(size_t));

output.write(reinterpret_cast<const char*>(&dim_), sizeof(size_t));
output.write(reinterpret_cast<const char*>(&padded_dim_), sizeof(size_t));
Expand Down Expand Up @@ -192,10 +205,10 @@ void HierarchicalNSW::save(const char* filename) const {

output.write(
reinterpret_cast<const char*>(data_level0_memory_.get()),
static_cast<std::streamsize>(num_stored_points_ * level0_record_bytes_)
static_cast<std::streamsize>(num_stored_points * level0_record_bytes_)
);

for (size_t i = 0; i < num_stored_points_; i++) {
for (size_t i = 0; i < num_stored_points; i++) {
unsigned int link_list_size =
element_levels_[i] > 0 ? upper_layer_links_bytes_ * element_levels_[i] : 0;
output.write(reinterpret_cast<const char*>(&link_list_size), sizeof(unsigned int));
Expand Down
23 changes: 16 additions & 7 deletions src/index/ivf.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
#include <memory>
#include <optional>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>

#include "file_replacement.hpp"
#include "ivf_search.hpp"
#include "rabitqlib/core/data_layout.hpp"
#include "rabitqlib/core/encoder.hpp"
Expand Down Expand Up @@ -638,15 +640,12 @@ void IVF::reserve(PID cluster_id, size_t num_points) {
std::copy_n(old_cluster.ex_data(), ex_data_bytes(num), grown.ex_data());
}
std::copy_n(old_cluster.ids(), num, grown.ids());
std::vector<size_t> cluster_sizes(num_clusters_);
for (size_t i = 0; i < num_clusters_; ++i) {
cluster_sizes[i] = clusters_[i].num();
}

/* Commit. Rebuilding the cluster list reuses its capacity, so it cannot throw. */
/* Commit. Only this cluster's storage moved, so only its view changes. */
cs = std::move(grown);
clusters_.clear();
init_clusters(cluster_sizes);
clusters_[cluster_id].reset(
num, cs.batch_data(), rerank_vector_bytes() > 0 ? cs.ex_data() : nullptr, cs.ids()
);
}

size_t IVF::remove(const PID* ids_to_remove, size_t num_points) {
Expand Down Expand Up @@ -703,6 +702,16 @@ void IVF::save(const char* filename) const {
throw std::invalid_argument("IVF save filename must not be empty");
}

// An HNSW initializer writes its sidecar next to the path it is given.
const auto write = [this](const std::string& path) { write_file(path.c_str()); };
if (initializer_type_ == InitializerType::HNSW) {
index_detail::save_replacing(filename, {".hnsw"}, write);
} else {
index_detail::save_replacing(filename, {}, write);
}
}

void IVF::write_file(const char* filename) const {
std::ofstream output(rabitqlib::io_impl::filesystem_path(filename), std::ios::binary);
output.exceptions(std::ios::failbit | std::ios::badbit);
const uint32_t flags = raw_reranking_ ? 1 : 0;
Expand Down
7 changes: 7 additions & 0 deletions src/index/qg.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#include <utility>
#include <vector>

#include "file_replacement.hpp"
#include "rabitqlib/core/data_layout.hpp"
#include "rabitqlib/core/encoder.hpp"
#include "rabitqlib/core/metric.hpp"
Expand Down Expand Up @@ -261,6 +262,12 @@ void QuantizedGraph<float>::save(const char* filename) const {
if (filename == nullptr || filename[0] == '\0') {
throw std::invalid_argument("QuantizedGraph save filename must not be empty");
}
index_detail::save_replacing(filename, {}, [this](const std::string& path) {
write_file(path.c_str());
});
}

void QuantizedGraph<float>::write_file(const char* filename) const {
std::ofstream output(rabitqlib::io_impl::filesystem_path(filename), std::ios::binary);
if (!output.is_open()) {
throw std::runtime_error("Cannot open quantized graph file for writing");
Expand Down
6 changes: 4 additions & 2 deletions src/index/qg_builder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,9 @@ void QGBuilder::search_new_neighbors() {
}

void QGBuilder::add_reverse_edges(bool final_pass) {
std::vector<std::mutex> locks(num_points_);
// Striped locks bound this pass's memory independently of the point count.
constexpr size_t kLockStripes = 4096;
std::vector<std::mutex> locks(std::min(num_points_, kLockStripes));
for (auto& row : reverse_buffer_) {
row.clear();
}
Expand All @@ -539,7 +541,7 @@ void QGBuilder::add_reverse_edges(bool final_pass) {
continue;
}

std::lock_guard lock(locks[destination]);
std::lock_guard lock(locks[destination % locks.size()]);
const size_t missing_slots =
max_degree_ - std::min(max_degree_, destination_neighbors.size());
if (reverse_buffer_[destination].size() <
Expand Down
18 changes: 18 additions & 0 deletions tests/python/test_input_boundaries.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,24 @@ def test_path_roundtrip(built_index, tmp_path, kind):
np.testing.assert_allclose(actual[1], expected[1], rtol=1e-6, atol=1e-6)


def test_save_replaces_existing_files_without_leftovers(built_index, tmp_path):
index, queries = built_index
filename = tmp_path / "replaced.index"
filename.write_bytes(b"stale")
expected_files = {filename.name}
if isinstance(index, IvfIndex) and index.initializer == "hnsw":
Path(str(filename) + ".hnsw").write_bytes(b"stale")
expected_files.add(filename.name + ".hnsw")
for _ in range(2):
index.save(filename)
assert {path.name for path in tmp_path.iterdir()} == expected_files
restored = type(index).load(filename)
expected = index.search(queries, 3, 32)
actual = restored.search(queries, 3, 32)
np.testing.assert_array_equal(actual[0], expected[0])
np.testing.assert_allclose(actual[1], expected[1], rtol=1e-6, atol=1e-6)


@pytest.mark.skipif(os.name != "posix", reason="POSIX native path bytes")
def test_path_preserves_undecodable_bytes(built_index, tmp_path):
index, queries = built_index
Expand Down
Loading
Loading