Skip to content

Commit 6eded88

Browse files
committed
fix(builder): make restoreGraph deterministic across independent runs
1 parent cb0679e commit 6eded88

4 files changed

Lines changed: 228 additions & 34 deletions

File tree

cpp/deglib/include/builder.h

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -631,9 +631,8 @@ class EvenRegularGraphBuilder {
631631
const auto dist_func_param = graph.getFeatureSpace().get_dist_func_param();
632632

633633
// find good neighbors for the new vertex
634-
//auto distrib = std::uniform_int_distribution<uint32_t>(0, uint32_t(graph.size() - 1));
635-
//const std::vector<uint32_t> entry_vertex_indices = { distrib(this->rnd_) };
636-
const std::vector<uint32_t> entry_vertex_indices = { 0 };
634+
auto distrib = std::uniform_int_distribution<uint32_t>(0, uint32_t(graph.size() - 1));
635+
const std::vector<uint32_t> entry_vertex_indices = { distrib(this->rnd_) };
637636
auto top_list = graph.search(entry_vertex_indices, new_vertex_feature, this->extend_eps_, std::max(uint32_t(this->extend_k_), edges_per_vertex));
638637
const auto results = topListAscending(top_list);
639638

@@ -816,7 +815,7 @@ class EvenRegularGraphBuilder {
816815
const auto edges_per_vertex = std::min(graph.size(), uint32_t(graph.getEdgesPerVertex()));
817816

818817
// 2 find pairs or groups of vertices which can reach each other
819-
auto unique_groups = std::unordered_set<std::shared_ptr<ReachableGroup>>();
818+
auto unique_groups = std::vector<std::shared_ptr<ReachableGroup>>();
820819
{
821820
auto path_map = UnionFind(edges_per_vertex);
822821
auto reachable_groups = std::unordered_map<uint32_t, std::shared_ptr<ReachableGroup>>();
@@ -888,23 +887,28 @@ class EvenRegularGraphBuilder {
888887
neighbor_check_depth++;
889888
}
890889

891-
// copy the unique groups
892-
for (const auto involved_index : involved_indices)
893-
unique_groups.emplace(reachable_groups.at(path_map.Find(involved_index)));
890+
// copy the unique groups deterministically by root vertex ID
891+
auto added_roots = std::unordered_set<uint32_t>();
892+
for (const auto involved_index : involved_indices) {
893+
uint32_t root = path_map.Find(involved_index);
894+
if (added_roots.insert(root).second) {
895+
unique_groups.push_back(reachable_groups.at(root));
896+
}
897+
}
894898
}
895899

896900
// 2.2 get all isolated vertices
897-
auto isolated_groups = std::unordered_set<std::shared_ptr<ReachableGroup>>();
898-
for(const auto group : unique_groups)
901+
auto isolated_groups = std::vector<std::shared_ptr<ReachableGroup>>();
902+
for(const auto& group : unique_groups)
899903
if(group->size() == 1)
900-
isolated_groups.emplace(group);
904+
isolated_groups.push_back(group);
901905

902906
// 2.3 find for every isolated vertex the best other involved vertex which is part of a unique group
903907
auto new_edges = std::vector<GraphEdge>();
904908
const auto& feature_space = graph.getFeatureSpace();
905909
const auto dist_func = feature_space.get_dist_func();
906910
const auto dist_func_param = feature_space.get_dist_func_param();
907-
for(const auto isolated_group : isolated_groups) {
911+
for(const auto& isolated_group : isolated_groups) {
908912

909913
// are you still isolated?
910914
if(isolated_group->size() > 1)
@@ -917,14 +921,14 @@ class EvenRegularGraphBuilder {
917921
uint32_t best_candidate_index = 0;
918922
float best_candidate_distance = std::numeric_limits<float>::max();
919923
deglib::builder::ReachableGroup* best_candidate_group = nullptr;
920-
for (const auto candidate_group : unique_groups) {
924+
for (const auto& candidate_group : unique_groups) {
921925

922926
// skip all groups which do not have enough vertices missing an edge
923927
const auto& missing_edges = candidate_group->getMissingEdges();
924928
if(missing_edges.size() <= 2)
925929
continue;
926930

927-
// find the candidate with the best distance to the isolated vertex
931+
// missing_edges is already a sorted const std::vector<uint32_t>& from ReachableGroup!
928932
for (const auto candidate : missing_edges) {
929933
const auto candidate_feature = graph.getFeatureVector(candidate);
930934
const auto distance = dist_func(isolated_vertex_feature, candidate_feature, dist_func_param);
@@ -946,15 +950,17 @@ class EvenRegularGraphBuilder {
946950
isolated_group->hasEdge(isolated_vertex);
947951
best_candidate_group->copyFrom(*isolated_group);
948952

949-
unique_groups.erase(isolated_group);
953+
unique_groups.erase(std::remove(unique_groups.begin(), unique_groups.end(), isolated_group), unique_groups.end());
950954
}
951955

952956
// 3 reconnect the groups
953-
auto reachable_groups = std::vector(unique_groups.begin(), unique_groups.end());
957+
auto reachable_groups = unique_groups;
954958

955-
// Define a custom comparison function based on the size of the sets
959+
// Define a custom comparison function based on the size of the sets, with a deterministic tie-breaker
956960
auto compareBySize = [](const std::shared_ptr<deglib::builder::ReachableGroup>& a, const std::shared_ptr<deglib::builder::ReachableGroup>& b) {
957-
return a->getMissingEdgeSize() < b->getMissingEdgeSize(); // < is ascending, > is descending
961+
if (a->getMissingEdgeSize() != b->getMissingEdgeSize())
962+
return a->getMissingEdgeSize() < b->getMissingEdgeSize(); // < is ascending, > is descending
963+
return a->getVertexIndex() < b->getVertexIndex();
958964
};
959965

960966
// Sort the groups by size in ascending order
@@ -964,36 +970,35 @@ class EvenRegularGraphBuilder {
964970
while(reachable_groups.size() >= 2) {
965971
auto& reachable_group = *reachable_groups[reachable_groups.size()-1];
966972
auto& other_group = *reachable_groups[reachable_groups.size()-2];
967-
auto& reachable_vertices = reachable_group.getMissingEdges();
968-
auto& other_vertices = other_group.getMissingEdges();
973+
974+
const auto& reachable_missing = reachable_group.getMissingEdges();
975+
const auto& other_missing = other_group.getMissingEdges();
969976

970-
auto best_other_it = reachable_vertices.begin();
971-
auto best_reachable_it = reachable_vertices.begin();
977+
uint32_t best_reachable_index = 0;
978+
uint32_t best_other_index = 0;
972979
auto best_other_distance = std::numeric_limits<float>::max();
973980

974981
// iterate over all its entries to find a vertex which is still missing an edge
975-
for(auto reachable_it = reachable_vertices.begin(); reachable_it != reachable_vertices.end(); ++reachable_it) {
976-
const auto reachable_index = *reachable_it;
982+
for(const auto reachable_index : reachable_missing) {
977983
const auto reachable_feature = graph.getFeatureVector(reachable_index);
978984

979985
// find another vertex in a smaller group, also missing an edge
980986
// the other vertex and reachable_index can not share an edge yet, otherwise they would be in the same group due to step 2.1
981-
for(auto other_it = other_vertices.begin(); other_it != other_vertices.end(); ++other_it) {
982-
const auto other_index = *other_it;
987+
for(const auto other_index : other_missing) {
983988
const auto other_feature = graph.getFeatureVector(other_index);
984989
const auto candidate_dist = dist_func(reachable_feature, other_feature, dist_func_param);
985990

986991
if(candidate_dist < best_other_distance) {
987-
best_other_it = other_it;
988-
best_reachable_it = reachable_it;
992+
best_reachable_index = reachable_index;
993+
best_other_index = other_index;
989994
best_other_distance = candidate_dist;
990995
}
991996
}
992997
}
993998

994999
// connect reachable_index and other_index
995-
const auto reachable_index = *best_reachable_it;
996-
const auto other_index = *best_other_it;
1000+
const auto reachable_index = best_reachable_index;
1001+
const auto other_index = best_other_index;
9971002
graph.changeEdge(reachable_index, reachable_index, other_index, best_other_distance);
9981003
graph.changeEdge(other_index, other_index, reachable_index, best_other_distance);
9991004

@@ -1099,7 +1104,11 @@ class EvenRegularGraphBuilder {
10991104

11001105
// Define a custom comparison function based on the size of the sets
11011106
auto compareByWeight = [](const GraphEdge& a, const GraphEdge& b) {
1102-
return a.weight > b.weight; // < is ascending, > is descending
1107+
if (a.weight != b.weight)
1108+
return a.weight > b.weight; // < is ascending, > is descending
1109+
if (a.from_vertex != b.from_vertex)
1110+
return a.from_vertex < b.from_vertex;
1111+
return a.to_vertex < b.to_vertex;
11031112
};
11041113

11051114
// Sort the groups by size in ascending order

cpp/test/CMakeLists.txt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,11 @@ add_deglib_test(test_l2_regression src/regression/metric/test_l2_regression.cpp)
2828
add_deglib_test(test_innerproduct_regression src/regression/metric/test_innerproduct_regression.cpp)
2929
add_deglib_test(test_l2_uint8_regression src/regression/metric/test_l2_uint8_regression.cpp)
3030

31-
# Meta target to build all metric regression tests
32-
add_custom_target(test_regression DEPENDS test_l2_regression test_innerproduct_regression test_l2_uint8_regression)
31+
# Builder Regression tests
32+
add_deglib_test(test_l2_builder_regression src/regression/builder/test_l2_builder_regression.cpp)
33+
34+
# Meta target to build all regression tests
35+
add_custom_target(test_regression DEPENDS test_l2_regression test_innerproduct_regression test_l2_uint8_regression test_l2_builder_regression)
3336

3437

3538

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
#include "test_regression.h"
2+
3+
// Regression tests for the graph builder (EvenRegularGraphBuilder) using the L2 metric.
4+
// Tests all three deglib::builder::OptimizationTarget modes:
5+
// - LowLID, HighLID, StreamingData
6+
7+
TEST(DeglibBuilderRegressionL2, OptimizationTargetsBenchmark)
8+
{
9+
const size_t dim = 128;
10+
const size_t base_count = 100000;
11+
const size_t query_count = 100;
12+
const size_t num_clusters = 1000;
13+
14+
std::vector<float> base_data;
15+
std::vector<float> query_data;
16+
generate_synthetic_clustered_dataset(base_count, dim, base_data, query_data, query_count, num_clusters);
17+
18+
auto gt_data = compute_groundtruth_l2(base_data, base_count, query_data, query_count, dim, 10);
19+
20+
run_regression_test("LowLID", deglib::Metric::L2, 26000.0, 10.0, 0.959,
21+
base_data.data(), query_data.data(), base_count, query_count, dim, gt_data,
22+
deglib::distances::fp32_l2::L2Float{}, 100,
23+
deglib::builder::OptimizationTarget::LowLID);
24+
25+
run_regression_test("HighLID", deglib::Metric::L2, 15000.0, 10.0, 0.888,
26+
base_data.data(), query_data.data(), base_count, query_count, dim, gt_data,
27+
deglib::distances::fp32_l2::L2Float{}, 100,
28+
deglib::builder::OptimizationTarget::HighLID);
29+
30+
run_regression_test("StreamingData", deglib::Metric::L2, 24000.0, 24.0, 0.96,
31+
base_data.data(), query_data.data(), base_count, query_count, dim, gt_data,
32+
deglib::distances::fp32_l2::L2Float{}, 100,
33+
deglib::builder::OptimizationTarget::StreamingData);
34+
}
35+
36+
static std::vector<uint32_t> build_graph_for_determinism(
37+
deglib::builder::OptimizationTarget optimization_target,
38+
size_t dim, size_t base_count, uint32_t edges_per_vertex,
39+
const std::vector<float>& base_data)
40+
{
41+
const deglib::FloatSpace feature_space(dim, deglib::Metric::L2);
42+
deglib::graph::SizeBoundedGraph graph(static_cast<uint32_t>(base_count), edges_per_vertex,
43+
std::move(feature_space));
44+
45+
std::mt19937 rng(1337);
46+
const uint8_t extend_k = static_cast<uint8_t>(edges_per_vertex);
47+
const float extend_eps = 0.1f;
48+
const uint8_t improve_k = 0;
49+
const float improve_eps = 0.0f;
50+
const uint8_t max_path_length = 5;
51+
const uint32_t swap_tries = 0;
52+
const uint32_t additional_swap_tries = 0;
53+
54+
deglib::builder::EvenRegularGraphBuilder builder(graph, rng, optimization_target,
55+
extend_k, extend_eps, improve_k, improve_eps,
56+
max_path_length, swap_tries, additional_swap_tries);
57+
builder.setThreadCount(1);
58+
59+
const size_t feature_bytes = dim * sizeof(float);
60+
const std::byte* base_bytes = reinterpret_cast<const std::byte*>(base_data.data());
61+
for (size_t i = 0; i < base_count; ++i)
62+
{
63+
const std::byte* ptr = base_bytes + i * feature_bytes;
64+
std::vector<std::byte> feat_vec(ptr, ptr + feature_bytes);
65+
builder.addEntry(static_cast<uint32_t>(i), std::move(feat_vec));
66+
}
67+
68+
auto build_callback = [](deglib::builder::BuilderStatus& status) {};
69+
builder.build(build_callback);
70+
71+
std::vector<uint32_t> neighbors;
72+
neighbors.reserve(base_count * edges_per_vertex);
73+
for (uint32_t v = 0; v < base_count; ++v)
74+
{
75+
const uint32_t* nb = graph.getNeighborIndices(v);
76+
for (uint32_t e = 0; e < edges_per_vertex; ++e)
77+
{
78+
neighbors.push_back(nb[e]);
79+
}
80+
}
81+
return neighbors;
82+
}
83+
84+
TEST(DeglibBuilderRegressionL2, StreamingDataDeterminism)
85+
{
86+
const size_t dim = 64;
87+
const size_t base_count = 10000;
88+
const uint32_t edges_per_vertex = 32;
89+
90+
std::vector<float> base_data;
91+
std::vector<float> query_data;
92+
generate_synthetic_clustered_dataset(base_count, dim, base_data, query_data, 10, 50);
93+
94+
auto graph1_neighbors = build_graph_for_determinism(
95+
deglib::builder::OptimizationTarget::StreamingData, dim, base_count, edges_per_vertex, base_data);
96+
auto graph2_neighbors = build_graph_for_determinism(
97+
deglib::builder::OptimizationTarget::StreamingData, dim, base_count, edges_per_vertex, base_data);
98+
99+
ASSERT_EQ(graph1_neighbors.size(), graph2_neighbors.size())
100+
<< "Graph neighbor count mismatch between two builds";
101+
102+
size_t mismatch_count = 0;
103+
for (size_t i = 0; i < graph1_neighbors.size(); ++i)
104+
{
105+
if (graph1_neighbors[i] != graph2_neighbors[i])
106+
mismatch_count++;
107+
}
108+
109+
double mismatch_pct = 100.0 * mismatch_count / graph1_neighbors.size();
110+
std::cout << "[StreamingDataDeterminism] mismatches: " << mismatch_count
111+
<< " / " << graph1_neighbors.size()
112+
<< " (" << mismatch_pct << "%)" << std::endl;
113+
114+
EXPECT_EQ(0u, mismatch_count) << "Graph was not deterministic within the same process";
115+
}
116+
117+
TEST(DeglibBuilderRegressionL2, LowLIDDeterminism)
118+
{
119+
const size_t dim = 64;
120+
const size_t base_count = 10000;
121+
const uint32_t edges_per_vertex = 32;
122+
123+
std::vector<float> base_data;
124+
std::vector<float> query_data;
125+
generate_synthetic_clustered_dataset(base_count, dim, base_data, query_data, 10, 50);
126+
127+
auto graph1_neighbors = build_graph_for_determinism(
128+
deglib::builder::OptimizationTarget::LowLID, dim, base_count, edges_per_vertex, base_data);
129+
auto graph2_neighbors = build_graph_for_determinism(
130+
deglib::builder::OptimizationTarget::LowLID, dim, base_count, edges_per_vertex, base_data);
131+
132+
ASSERT_EQ(graph1_neighbors.size(), graph2_neighbors.size())
133+
<< "Graph neighbor count mismatch between two builds";
134+
135+
size_t mismatch_count = 0;
136+
for (size_t i = 0; i < graph1_neighbors.size(); ++i)
137+
{
138+
if (graph1_neighbors[i] != graph2_neighbors[i])
139+
mismatch_count++;
140+
}
141+
142+
double mismatch_pct = 100.0 * mismatch_count / graph1_neighbors.size();
143+
std::cout << "[LowLIDDeterminism] mismatches: " << mismatch_count
144+
<< " / " << graph1_neighbors.size()
145+
<< " (" << mismatch_pct << "%)" << std::endl;
146+
147+
EXPECT_EQ(0u, mismatch_count) << "Graph was not deterministic within the same process";
148+
}
149+
150+
TEST(DeglibBuilderRegressionL2, HighLIDDeterminism)
151+
{
152+
const size_t dim = 64;
153+
const size_t base_count = 10000;
154+
const uint32_t edges_per_vertex = 32;
155+
156+
std::vector<float> base_data;
157+
std::vector<float> query_data;
158+
generate_synthetic_clustered_dataset(base_count, dim, base_data, query_data, 10, 50);
159+
160+
auto graph1_neighbors = build_graph_for_determinism(
161+
deglib::builder::OptimizationTarget::HighLID, dim, base_count, edges_per_vertex, base_data);
162+
auto graph2_neighbors = build_graph_for_determinism(
163+
deglib::builder::OptimizationTarget::HighLID, dim, base_count, edges_per_vertex, base_data);
164+
165+
ASSERT_EQ(graph1_neighbors.size(), graph2_neighbors.size())
166+
<< "Graph neighbor count mismatch between two builds";
167+
168+
size_t mismatch_count = 0;
169+
for (size_t i = 0; i < graph1_neighbors.size(); ++i)
170+
{
171+
if (graph1_neighbors[i] != graph2_neighbors[i])
172+
mismatch_count++;
173+
}
174+
175+
double mismatch_pct = 100.0 * mismatch_count / graph1_neighbors.size();
176+
std::cout << "[HighLIDDeterminism] mismatches: " << mismatch_count
177+
<< " / " << graph1_neighbors.size()
178+
<< " (" << mismatch_pct << "%)" << std::endl;
179+
180+
EXPECT_EQ(0u, mismatch_count) << "Graph was not deterministic within the same process";
181+
}

cpp/test/src/regression/test_regression.h

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,20 +180,21 @@ inline static void generate_synthetic_clustered_dataset_uint8(size_t count, size
180180
// Universal regression benchmark runner function for any metric
181181
// num_runs: number of measured search runs (averaged for QPS/recall).
182182
// Higher values extend the total search measurement window, reducing QPS noise.
183+
// optimization_target: controls the graph build strategy (LowLID, HighLID, StreamingData).
183184
inline static void run_regression_test(const char* name, deglib::Metric metric, double min_qps, double max_build_secs,
184185
double min_recall, const void* base_data,
185186
const void* query_data, size_t base_count, size_t query_count,
186187
size_t dim, const std::vector<std::vector<uint32_t>>& gt_data,
187188
std::optional<deglib::DistanceVariant> dist_variant = std::nullopt,
188-
size_t num_runs = 5)
189+
size_t num_runs = 5,
190+
deglib::builder::OptimizationTarget optimization_target = deglib::builder::OptimizationTarget::LowLID)
189191
{
190192
std::cout << "--- Testing Instruction Variant: " << name << " ---" << std::endl;
191193

192194
const uint32_t search_k = 10;
193195
const float search_eps = 0.05f;
194196

195197
const uint32_t edges_per_vertex = 32;
196-
const deglib::builder::OptimizationTarget optimization_target = deglib::builder::OptimizationTarget::LowLID;
197198
const uint8_t extend_k = static_cast<uint8_t>(edges_per_vertex);
198199
const float extend_eps = 0.1f;
199200
const uint8_t improve_k = 0;

0 commit comments

Comments
 (0)