From 3f055f7e2ac7b1e11af61cc0e0a412c49a81f6cc Mon Sep 17 00:00:00 2001 From: gouyt13clear Date: Sat, 31 Oct 2026 08:57:50 +0800 Subject: [PATCH 1/3] fix(index): replace saved index files atomically HNSW, IVF and SymphonyQG save to a temporary file beside the destination and rename it into place, so a failed save leaves an existing index unchanged and removes what it wrote. IVF renames its HNSW sidecar first. A replaced file keeps its permission bits, but not its owner or other hard links; symbolic links and other non-regular destinations are still written in place. HNSW now serializes one loaded point count instead of the bytes of the atomic counter. --- docs/docs/index/usage.md | 6 + include/rabitqlib/index/hnsw/hnsw.hpp | 3 + include/rabitqlib/index/ivf/ivf.hpp | 3 + include/rabitqlib/index/symqg/qg.hpp | 3 + src/index/file_replacement.hpp | 99 ++++++++++++ src/index/hnsw.cpp | 19 ++- src/index/ivf.cpp | 12 ++ src/index/qg.cpp | 7 + tests/python/test_input_boundaries.py | 18 +++ .../rabitqlib/index/file_replacement_test.cpp | 150 ++++++++++++++++++ 10 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 src/index/file_replacement.hpp create mode 100644 tests/unit/rabitqlib/index/file_replacement_test.cpp diff --git a/docs/docs/index/usage.md b/docs/docs/index/usage.md index 4589fd12..894d25d8 100644 --- a/docs/docs/index/usage.md +++ b/docs/docs/index/usage.md @@ -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 diff --git a/include/rabitqlib/index/hnsw/hnsw.hpp b/include/rabitqlib/index/hnsw/hnsw.hpp index a72ccae4..526a557a 100644 --- a/include/rabitqlib/index/hnsw/hnsw.hpp +++ b/include/rabitqlib/index/hnsw/hnsw.hpp @@ -362,6 +362,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( link_lists_[internal_id].get() + ((level - 1) * upper_layer_links_bytes_) diff --git a/include/rabitqlib/index/ivf/ivf.hpp b/include/rabitqlib/index/ivf/ivf.hpp index a1404539..49127902 100644 --- a/include/rabitqlib/index/ivf/ivf.hpp +++ b/include/rabitqlib/index/ivf/ivf.hpp @@ -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 diff --git a/include/rabitqlib/index/symqg/qg.hpp b/include/rabitqlib/index/symqg/qg.hpp index 7e6a9b01..f0023d20 100644 --- a/include/rabitqlib/index/symqg/qg.hpp +++ b/include/rabitqlib/index/symqg/qg.hpp @@ -127,6 +127,9 @@ class QuantizedGraph { 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); diff --git a/src/index/file_replacement.hpp b/src/index/file_replacement.hpp new file mode 100644 index 00000000..fe836cbf --- /dev/null +++ b/src/index/file_replacement.hpp @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include +#include +#include + +#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 +void save_replacing( + const std::string& filename, + std::initializer_list 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(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 diff --git a/src/index/hnsw.cpp b/src/index/hnsw.cpp index c51f7918..87447731 100644 --- a/src/index/hnsw.cpp +++ b/src/index/hnsw.cpp @@ -16,10 +16,12 @@ #include #include #include +#include #include #include #include +#include "file_replacement.hpp" #include "rabitqlib/core/data_layout.hpp" #include "rabitqlib/core/encoder.hpp" #include "rabitqlib/core/estimator.hpp" @@ -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(&max_elements_), sizeof(size_t)); - output.write(reinterpret_cast(&num_stored_points_), sizeof(size_t)); + output.write(reinterpret_cast(&num_stored_points), sizeof(size_t)); output.write(reinterpret_cast(&dim_), sizeof(size_t)); output.write(reinterpret_cast(&padded_dim_), sizeof(size_t)); @@ -192,10 +205,10 @@ void HierarchicalNSW::save(const char* filename) const { output.write( reinterpret_cast(data_level0_memory_.get()), - static_cast(num_stored_points_ * level0_record_bytes_) + static_cast(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(&link_list_size), sizeof(unsigned int)); diff --git a/src/index/ivf.cpp b/src/index/ivf.cpp index e2237a0b..8aa5c9a0 100644 --- a/src/index/ivf.cpp +++ b/src/index/ivf.cpp @@ -15,10 +15,12 @@ #include #include #include +#include #include #include #include +#include "file_replacement.hpp" #include "ivf_search.hpp" #include "rabitqlib/core/data_layout.hpp" #include "rabitqlib/core/encoder.hpp" @@ -703,6 +705,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; diff --git a/src/index/qg.cpp b/src/index/qg.cpp index 1615f609..2fcc1bfc 100644 --- a/src/index/qg.cpp +++ b/src/index/qg.cpp @@ -15,6 +15,7 @@ #include #include +#include "file_replacement.hpp" #include "rabitqlib/core/data_layout.hpp" #include "rabitqlib/core/encoder.hpp" #include "rabitqlib/core/metric.hpp" @@ -261,6 +262,12 @@ void QuantizedGraph::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::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"); diff --git a/tests/python/test_input_boundaries.py b/tests/python/test_input_boundaries.py index d04cd1d7..f9c5ff26 100644 --- a/tests/python/test_input_boundaries.py +++ b/tests/python/test_input_boundaries.py @@ -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 diff --git a/tests/unit/rabitqlib/index/file_replacement_test.cpp b/tests/unit/rabitqlib/index/file_replacement_test.cpp new file mode 100644 index 00000000..e63e2dae --- /dev/null +++ b/tests/unit/rabitqlib/index/file_replacement_test.cpp @@ -0,0 +1,150 @@ +#include "file_replacement.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +namespace fs = std::filesystem; +using rabitqlib::index_detail::save_replacing; + +class FileReplacementTest : public ::testing::Test { + protected: + fs::path directory_; + std::string path_; + + void SetUp() override { + directory_ = fs::path(::testing::TempDir()) / + (std::string("rabitq_file_replacement_") + + ::testing::UnitTest::GetInstance()->current_test_info()->name()); + fs::remove_all(directory_); + fs::create_directories(directory_); + path_ = (directory_ / "out.index").string(); + } + + void TearDown() override { + std::error_code ignored; + fs::remove_all(directory_, ignored); + } + + static void write_text(const std::string& path, const std::string& text) { + std::ofstream output(path, std::ios::binary | std::ios::trunc); + output << text; + if (!output) { + throw std::runtime_error("cannot write test file"); + } + } + + static std::string read_text(const std::string& path) { + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; + } + + [[nodiscard]] size_t num_entries() const { + return static_cast( + std::distance(fs::directory_iterator(directory_), fs::directory_iterator()) + ); + } +}; + +TEST_F(FileReplacementTest, ReplacesFileAndSidecarWithoutLeftovers) { + write_text(path_, "old"); + write_text(path_ + ".hnsw", "old sidecar"); + std::string written_path; + save_replacing(path_, {".hnsw"}, [&](const std::string& path) { + written_path = path; + write_text(path, "new"); + write_text(path + ".hnsw", "new sidecar"); + }); + EXPECT_NE(written_path, path_); + EXPECT_EQ(read_text(path_), "new"); + EXPECT_EQ(read_text(path_ + ".hnsw"), "new sidecar"); + EXPECT_EQ(num_entries(), 2U); +} + +TEST_F(FileReplacementTest, KeepsPermissionsOfReplacedFiles) { +#ifdef _WIN32 + GTEST_SKIP() << "Windows exposes only a read-only attribute"; +#endif + constexpr auto kMain = + fs::perms::owner_read | fs::perms::owner_write | fs::perms::group_read; + constexpr auto kSidecar = fs::perms::owner_read | fs::perms::owner_write; + write_text(path_, "old"); + write_text(path_ + ".hnsw", "old sidecar"); + fs::permissions(path_, kMain); + fs::permissions(path_ + ".hnsw", kSidecar); + save_replacing(path_, {".hnsw"}, [](const std::string& path) { + write_text(path, "new"); + write_text(path + ".hnsw", "new sidecar"); + }); + EXPECT_EQ(read_text(path_), "new"); + EXPECT_EQ(fs::status(path_).permissions(), kMain); + EXPECT_EQ(fs::status(path_ + ".hnsw").permissions(), kSidecar); +} + +TEST_F(FileReplacementTest, CreatesMissingDestination) { + save_replacing(path_, {}, [](const std::string& path) { write_text(path, "new"); }); + EXPECT_EQ(read_text(path_), "new"); + EXPECT_EQ(num_entries(), 1U); +} + +TEST_F(FileReplacementTest, FailedWriteKeepsFilesAndRemovesTemporaries) { + write_text(path_, "old"); + write_text(path_ + ".hnsw", "old sidecar"); + try { + save_replacing(path_, {".hnsw"}, [](const std::string& path) { + write_text(path, "partial"); + write_text(path + ".hnsw", "partial sidecar"); + throw std::runtime_error("injected write failure"); + }); + FAIL() << "the write failure must propagate"; + } catch (const std::runtime_error& error) { + EXPECT_STREQ(error.what(), "injected write failure"); + } + EXPECT_EQ(read_text(path_), "old"); + EXPECT_EQ(read_text(path_ + ".hnsw"), "old sidecar"); + EXPECT_EQ(num_entries(), 2U); +} + +TEST_F(FileReplacementTest, WritesThroughNonRegularDestinationInPlace) { + // A directory sidecar stands in for devices and FIFOs: nothing may be renamed onto it. + fs::create_directory(path_ + ".hnsw"); + std::string written_path; + EXPECT_THROW( + save_replacing( + path_, + {".hnsw"}, + [&](const std::string& path) { + written_path = path; + throw std::runtime_error("cannot open sidecar"); + } + ), + std::runtime_error + ); + EXPECT_EQ(written_path, path_); + EXPECT_TRUE(fs::is_directory(path_ + ".hnsw")); + EXPECT_EQ(num_entries(), 1U); +} + +TEST_F(FileReplacementTest, WritesThroughSymbolicLinkInPlace) { + const std::string target = (directory_ / "target.index").string(); + write_text(target, "old"); + std::error_code error; + fs::create_symlink(target, path_, error); + if (error) { + GTEST_SKIP() << "symbolic links are unavailable: " << error.message(); + } + save_replacing(path_, {}, [](const std::string& path) { write_text(path, "new"); }); + EXPECT_TRUE(fs::is_symlink(path_)); + EXPECT_EQ(read_text(target), "new"); + EXPECT_EQ(num_entries(), 2U); +} +} // namespace From 2de899281f1ef2764c329ae19e5915eb2e2c533e Mon Sep 17 00:00:00 2001 From: gouyt13clear Date: Sat, 31 Oct 2026 08:57:51 +0800 Subject: [PATCH 2/3] perf(index): refresh one IVF cluster view on reserve and stripe QG reverse-edge locks IVF::reserve grows one cluster, so only that cluster's view needs to point at the new storage; rebuilding every view cost O(num_clusters) per call. QGBuilder allocated one mutex per point for each reverse-edge pass; 4096 striped locks keep the same mutual exclusion with memory independent of the point count. --- src/index/ivf.cpp | 11 ++++------- src/index/qg_builder.cpp | 6 ++++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/index/ivf.cpp b/src/index/ivf.cpp index 8aa5c9a0..b3b6cec0 100644 --- a/src/index/ivf.cpp +++ b/src/index/ivf.cpp @@ -640,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 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) { diff --git a/src/index/qg_builder.cpp b/src/index/qg_builder.cpp index 9d361a41..9f2c6001 100644 --- a/src/index/qg_builder.cpp +++ b/src/index/qg_builder.cpp @@ -513,7 +513,9 @@ void QGBuilder::search_new_neighbors() { } void QGBuilder::add_reverse_edges(bool final_pass) { - std::vector locks(num_points_); + // Striped locks bound this pass's memory independently of the point count. + constexpr size_t kLockStripes = 4096; + std::vector locks(std::min(num_points_, kLockStripes)); for (auto& row : reverse_buffer_) { row.clear(); } @@ -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() < From d63afbb5818127caa00d28919f75fabcfdee74c8 Mon Sep 17 00:00:00 2001 From: gouyt13clear Date: Sat, 31 Oct 2026 08:57:51 +0800 Subject: [PATCH 3/3] docs(hnsw): state construct and add thread defaults Spell the construct default as every available thread (0 still auto-selects), document that one-thread add keeps links deterministic, and give Rotator dimensions a defined initial value. --- docs/docs/index/hnsw.md | 6 +++--- include/rabitqlib/core/rotator.hpp | 4 ++-- include/rabitqlib/index/hnsw/hnsw.hpp | 7 +++++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/docs/index/hnsw.md b/docs/docs/index/hnsw.md index a92cd2ae..aaef0f75 100644 --- a/docs/docs/index/hnsw.md +++ b/docs/docs/index/hnsw.md @@ -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::max(), bool fast_quantization = false ); ``` @@ -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 @@ -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()`. diff --git a/include/rabitqlib/core/rotator.hpp b/include/rabitqlib/core/rotator.hpp index 0452d371..6dfe31d2 100644 --- a/include/rabitqlib/core/rotator.hpp +++ b/include/rabitqlib/core/rotator.hpp @@ -27,8 +27,8 @@ namespace rabitqlib { template class Rotator { protected: - size_t dim_; - size_t padded_dim_; + size_t dim_ = 0; + size_t padded_dim_ = 0; public: explicit Rotator() = default; diff --git a/include/rabitqlib/index/hnsw/hnsw.hpp b/include/rabitqlib/index/hnsw/hnsw.hpp index 526a557a..e2a8b8c5 100644 --- a/include/rabitqlib/index/hnsw/hnsw.hpp +++ b/include/rabitqlib/index/hnsw/hnsw.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -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::max(), bool fast_quantization = false ); @@ -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 add(