Skip to content

Commit 1daa370

Browse files
committed
search and explore regression tests
1 parent 53cee7a commit 1daa370

5 files changed

Lines changed: 647 additions & 28 deletions

File tree

cpp/test/CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,7 @@ add_deglib_integration_test(test_distances_integration src/integration/test_dist
7373
# Regression tests (100k vectors, performance benchmarking — built but NOT registered with CTest)
7474
add_deglib_test_no_ctest(test_builder_regression src/regression/test_builder_regression.cpp)
7575
add_deglib_test_no_ctest(test_flas_regression src/regression/test_flas_regression.cpp)
76+
add_deglib_test_no_ctest(test_sizebounded_graph_regression src/regression/graph/test_sizebounded_graph_regression.cpp)
77+
add_deglib_test_no_ctest(test_readonly_graph_regression src/regression/graph/test_readonly_graph_regression.cpp)
78+
7679

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
#include "common/test_helpers.h"
2+
3+
// ============================================================================
4+
// ReadOnlyGraph Search & Explore Regression Benchmarks (100x averaged)
5+
// ============================================================================
6+
// Measures average throughput (QPS), latency (ms), and recall for search()
7+
// and explore() across 100 iterations on ReadOnlyGraph instances.
8+
// ============================================================================
9+
10+
TEST(ReadOnlyGraphRegression, SearchAndExplore_FP32_L2)
11+
{
12+
const size_t dim = 128;
13+
const size_t base_count = 100000;
14+
const size_t query_count = 100;
15+
const size_t num_clusters = 1000;
16+
const int benchmark_runs = 100;
17+
const uint32_t edges_per_vertex = 32;
18+
const float extend_eps = 0.1f;
19+
const float search_eps = 0.05f;
20+
const uint32_t search_k = 10;
21+
const uint32_t explore_max_calcs = 2000;
22+
23+
std::vector<float> base_data;
24+
std::vector<float> query_data;
25+
generate_synthetic_clustered_dataset(base_count, dim, base_data, query_data, query_count, num_clusters);
26+
27+
auto gt_data = compute_groundtruth_l2(base_data, base_count, query_data, query_count, dim, search_k);
28+
29+
deglib::FloatSpace feature_space(dim, deglib::Metric::FP32_L2);
30+
deglib::graph::SizeBoundedGraph mutable_graph(static_cast<uint32_t>(base_count), edges_per_vertex, feature_space);
31+
32+
std::mt19937 rnd(42);
33+
deglib::builder::EvenRegularGraphBuilder builder(
34+
mutable_graph, rnd, deglib::builder::OptimizationTarget::LowLID, edges_per_vertex, extend_eps, 0, 0.0f
35+
);
36+
builder.setThreadCount(1);
37+
38+
for (size_t i = 0; i < base_count; ++i) {
39+
std::vector<std::byte> feat(dim * sizeof(float));
40+
std::memcpy(feat.data(), &base_data[i * dim], dim * sizeof(float));
41+
builder.addEntry(static_cast<uint32_t>(i), std::move(feat));
42+
}
43+
auto dummy_callback = [](deglib::builder::BuilderStatus&) {};
44+
builder.build(dummy_callback, false);
45+
46+
// Convert to ReadOnlyGraph
47+
deglib::graph::ReadOnlyGraph graph(mutable_graph.size(), edges_per_vertex, feature_space, mutable_graph);
48+
49+
// 1. Search Benchmark (100x runs)
50+
uint32_t correct_search = 0;
51+
uint32_t total_gt = 0;
52+
auto t_start_search = std::chrono::high_resolution_clock::now();
53+
54+
for (int run = 0; run < benchmark_runs; ++run) {
55+
for (size_t q = 0; q < query_count; ++q) {
56+
auto results = graph.search(graph.getEntryVertexIndices(), reinterpret_cast<const std::byte*>(&query_data[q * dim]), search_eps, search_k);
57+
58+
if (run == 0) {
59+
std::unordered_set<uint32_t> gt_set(gt_data[q].begin(), gt_data[q].end());
60+
total_gt += static_cast<uint32_t>(gt_set.size());
61+
while (!results.empty()) {
62+
uint32_t ext_label = graph.getExternalLabel(results.top().getInternalIndex());
63+
if (gt_set.count(ext_label)) {
64+
correct_search++;
65+
}
66+
results.pop();
67+
}
68+
}
69+
}
70+
}
71+
auto t_end_search = std::chrono::high_resolution_clock::now();
72+
double total_search_ms = std::chrono::duration<double, std::milli>(t_end_search - t_start_search).count();
73+
size_t total_queries = query_count * benchmark_runs;
74+
double search_qps = (static_cast<double>(total_queries) / total_search_ms) * 1000.0;
75+
float search_recall = static_cast<float>(correct_search) / static_cast<float>(total_gt);
76+
77+
std::cout << "[BENCHMARK 100x] ReadOnlyGraph FP32_L2 search(): "
78+
<< total_search_ms << " ms total for " << total_queries << " queries ("
79+
<< (total_search_ms / total_queries) << " ms/q), "
80+
<< search_qps << " QPS, recall=" << (search_recall * 100.0f) << "%\n";
81+
82+
// 2. Explore Benchmark (100x runs)
83+
size_t explore_count_per_run = 1000;
84+
size_t total_explorations = explore_count_per_run * benchmark_runs;
85+
uint32_t correct_explore = 0;
86+
uint32_t total_explore_gt = 0;
87+
auto t_start_explore = std::chrono::high_resolution_clock::now();
88+
89+
for (int run = 0; run < benchmark_runs; ++run) {
90+
for (size_t i = 0; i < explore_count_per_run; ++i) {
91+
uint32_t entry_node = static_cast<uint32_t>((run * 13 + i) % base_count);
92+
auto results = graph.explore(entry_node, search_k, true, explore_max_calcs);
93+
94+
if (run == 0) {
95+
// Compute ground truth for this entry_node vector
96+
const float* entry_vec = &base_data[entry_node * dim];
97+
std::vector<std::pair<float, uint32_t>> dists(base_count);
98+
for (size_t b = 0; b < base_count; ++b) {
99+
float d = deglib::distances::fp32_l2::L2Float::compare(entry_vec, &base_data[b * dim], &dim);
100+
dists[b] = {d, static_cast<uint32_t>(b)};
101+
}
102+
std::partial_sort(dists.begin(), dists.begin() + search_k, dists.end());
103+
std::unordered_set<uint32_t> gt_set;
104+
for (size_t k = 0; k < search_k; ++k) gt_set.insert(dists[k].second);
105+
total_explore_gt += static_cast<uint32_t>(gt_set.size());
106+
107+
while (!results.empty()) {
108+
uint32_t ext_label = graph.getExternalLabel(results.top().getInternalIndex());
109+
if (gt_set.count(ext_label)) {
110+
correct_explore++;
111+
}
112+
results.pop();
113+
}
114+
}
115+
}
116+
}
117+
auto t_end_explore = std::chrono::high_resolution_clock::now();
118+
double total_explore_ms = std::chrono::duration<double, std::milli>(t_end_explore - t_start_explore).count();
119+
double explore_qps = (static_cast<double>(total_explorations) / total_explore_ms) * 1000.0;
120+
float explore_recall = static_cast<float>(correct_explore) / static_cast<float>(total_explore_gt);
121+
122+
std::cout << "[BENCHMARK 100x] ReadOnlyGraph FP32_L2 explore(): "
123+
<< total_explore_ms << " ms total for " << total_explorations << " explorations ("
124+
<< (total_explore_ms / total_explorations) << " ms/q), "
125+
<< explore_qps << " QPS, recall=" << (explore_recall * 100.0f) << "%\n";
126+
}
127+
128+
TEST(ReadOnlyGraphRegression, SearchAndExplore_FP32_InnerProduct)
129+
{
130+
const size_t dim = 128;
131+
const size_t base_count = 100000;
132+
const size_t query_count = 100;
133+
const size_t num_clusters = 1000;
134+
const int benchmark_runs = 100;
135+
const uint32_t edges_per_vertex = 32;
136+
const float extend_eps = 0.1f;
137+
const float search_eps = 0.05f;
138+
const uint32_t search_k = 10;
139+
const uint32_t explore_max_calcs = 2000;
140+
141+
std::vector<float> base_data;
142+
std::vector<float> query_data;
143+
generate_synthetic_clustered_dataset(base_count, dim, base_data, query_data, query_count, num_clusters);
144+
145+
auto gt_data = compute_groundtruth_innerproduct(base_data, base_count, query_data, query_count, dim, search_k);
146+
147+
deglib::FloatSpace feature_space(dim, deglib::Metric::FP32_InnerProduct);
148+
deglib::graph::SizeBoundedGraph mutable_graph(static_cast<uint32_t>(base_count), edges_per_vertex, feature_space);
149+
150+
std::mt19937 rnd(42);
151+
deglib::builder::EvenRegularGraphBuilder builder(
152+
mutable_graph, rnd, deglib::builder::OptimizationTarget::LowLID, edges_per_vertex, extend_eps, 0, 0.0f
153+
);
154+
builder.setThreadCount(1);
155+
156+
for (size_t i = 0; i < base_count; ++i) {
157+
std::vector<std::byte> feat(dim * sizeof(float));
158+
std::memcpy(feat.data(), &base_data[i * dim], dim * sizeof(float));
159+
builder.addEntry(static_cast<uint32_t>(i), std::move(feat));
160+
}
161+
auto dummy_callback = [](deglib::builder::BuilderStatus&) {};
162+
builder.build(dummy_callback, false);
163+
164+
// Convert to ReadOnlyGraph
165+
deglib::graph::ReadOnlyGraph graph(mutable_graph.size(), edges_per_vertex, feature_space, mutable_graph);
166+
167+
// Search Benchmark (100x runs)
168+
uint32_t correct_search = 0;
169+
uint32_t total_gt = 0;
170+
size_t total_queries = query_count * benchmark_runs;
171+
auto t_start_search = std::chrono::high_resolution_clock::now();
172+
for (int run = 0; run < benchmark_runs; ++run) {
173+
for (size_t q = 0; q < query_count; ++q) {
174+
auto results = graph.search(graph.getEntryVertexIndices(), reinterpret_cast<const std::byte*>(&query_data[q * dim]), search_eps, search_k);
175+
176+
if (run == 0) {
177+
std::unordered_set<uint32_t> gt_set(gt_data[q].begin(), gt_data[q].end());
178+
total_gt += static_cast<uint32_t>(gt_set.size());
179+
while (!results.empty()) {
180+
uint32_t ext_label = graph.getExternalLabel(results.top().getInternalIndex());
181+
if (gt_set.count(ext_label)) {
182+
correct_search++;
183+
}
184+
results.pop();
185+
}
186+
}
187+
}
188+
}
189+
auto t_end_search = std::chrono::high_resolution_clock::now();
190+
double total_search_ms = std::chrono::duration<double, std::milli>(t_end_search - t_start_search).count();
191+
double search_qps = (static_cast<double>(total_queries) / total_search_ms) * 1000.0;
192+
float search_recall = static_cast<float>(correct_search) / static_cast<float>(total_gt);
193+
194+
std::cout << "[BENCHMARK 100x] ReadOnlyGraph FP32_InnerProduct search(): "
195+
<< total_search_ms << " ms total (" << (total_search_ms / total_queries) << " ms/q), "
196+
<< search_qps << " QPS, recall=" << (search_recall * 100.0f) << "%\n";
197+
198+
// Explore Benchmark (100x runs)
199+
size_t explore_count_per_run = 1000;
200+
size_t total_explorations = explore_count_per_run * benchmark_runs;
201+
uint32_t correct_explore = 0;
202+
uint32_t total_explore_gt = 0;
203+
auto t_start_explore = std::chrono::high_resolution_clock::now();
204+
for (int run = 0; run < benchmark_runs; ++run) {
205+
for (size_t i = 0; i < explore_count_per_run; ++i) {
206+
uint32_t entry_node = static_cast<uint32_t>((run * 13 + i) % base_count);
207+
auto results = graph.explore(entry_node, search_k, true, explore_max_calcs);
208+
209+
if (run == 0) {
210+
// Compute ground truth for this entry_node vector
211+
const float* entry_vec = &base_data[entry_node * dim];
212+
std::vector<std::pair<float, uint32_t>> dists(base_count);
213+
for (size_t b = 0; b < base_count; ++b) {
214+
float d = deglib::distances::fp32_ip::InnerProductFloat::compare(entry_vec, &base_data[b * dim], &dim);
215+
dists[b] = {d, static_cast<uint32_t>(b)};
216+
}
217+
std::partial_sort(dists.begin(), dists.begin() + search_k, dists.end());
218+
std::unordered_set<uint32_t> gt_set;
219+
for (size_t k = 0; k < search_k; ++k) gt_set.insert(dists[k].second);
220+
total_explore_gt += static_cast<uint32_t>(gt_set.size());
221+
222+
while (!results.empty()) {
223+
uint32_t ext_label = graph.getExternalLabel(results.top().getInternalIndex());
224+
if (gt_set.count(ext_label)) {
225+
correct_explore++;
226+
}
227+
results.pop();
228+
}
229+
}
230+
}
231+
}
232+
auto t_end_explore = std::chrono::high_resolution_clock::now();
233+
double total_explore_ms = std::chrono::duration<double, std::milli>(t_end_explore - t_start_explore).count();
234+
double explore_qps = (static_cast<double>(total_explorations) / total_explore_ms) * 1000.0;
235+
float explore_recall = static_cast<float>(correct_explore) / static_cast<float>(total_explore_gt);
236+
237+
std::cout << "[BENCHMARK 100x] ReadOnlyGraph FP32_InnerProduct explore(): "
238+
<< total_explore_ms << " ms total (" << (total_explore_ms / total_explorations) << " ms/q), "
239+
<< explore_qps << " QPS, recall=" << (explore_recall * 100.0f) << "%\n";
240+
}

0 commit comments

Comments
 (0)