|
| 1 | +#pragma once |
| 2 | + |
| 3 | +/** |
| 4 | + * @file benchmark.h |
| 5 | + * @brief Main benchmark utilities for deglib without OpenMP dependency. |
| 6 | + */ |
| 7 | + |
| 8 | +#include <fmt/base.h> |
| 9 | +#include <fmt/format.h> |
| 10 | +#include <fmt/ostream.h> |
| 11 | +#include <fmt/ranges.h> |
| 12 | + |
| 13 | +#include <atomic> |
| 14 | +#include <filesystem> |
| 15 | +#include <fstream> |
| 16 | +#include <limits> |
| 17 | +#include <random> |
| 18 | + |
| 19 | +#include "dataset.h" |
| 20 | +#include "deglib.h" |
| 21 | +#include "file_io.h" |
| 22 | +#include "logging.h" |
| 23 | +#include "stats.h" |
| 24 | +#include "stopwatch.h" |
| 25 | + |
| 26 | +namespace deglib::benchmark { |
| 27 | + |
| 28 | +/** |
| 29 | + * @brief Compute baseline time per query using linear distance computation. |
| 30 | + */ |
| 31 | +static uint64_t compute_linear_search_baseline(const deglib::FeatureRepository& base_repository, |
| 32 | + const deglib::Metric metric, |
| 33 | + const uint32_t sample_size = 100, |
| 34 | + const deglib::cpu::InstructionSet instruction = deglib::cpu::InstructionSet::Auto) { |
| 35 | + const auto dims = base_repository.dims(); |
| 36 | + const auto base_size = base_repository.size(); |
| 37 | + const auto feature_space = deglib::FloatSpace(dims, metric, instruction); |
| 38 | + const auto dist_func = feature_space.get_dist_func(); |
| 39 | + const auto dist_func_param = feature_space.get_dist_func_param(); |
| 40 | + |
| 41 | + const auto query_count = std::min(sample_size, (uint32_t)base_size); |
| 42 | + |
| 43 | + std::vector<uint32_t> query_indices(query_count); |
| 44 | + std::mt19937 rng(7); |
| 45 | + std::uniform_int_distribution<uint32_t> dist(0, (uint32_t)base_size - 1); |
| 46 | + for (uint32_t i = 0; i < query_count; i++) { |
| 47 | + query_indices[i] = dist(rng); |
| 48 | + } |
| 49 | + |
| 50 | + log("Computing linear search baseline with {} random queries on {} base vectors...\n", query_count, base_size); |
| 51 | + |
| 52 | + StopW stopw = StopW(); |
| 53 | + |
| 54 | + float min_dist = std::numeric_limits<float>::max(); |
| 55 | + for (uint32_t q = 0; q < query_count; q++) { |
| 56 | + const auto query = base_repository.getFeature(query_indices[q]); |
| 57 | + |
| 58 | + for (uint32_t i = 0; i < base_size; i++) { |
| 59 | + const auto base_feature = base_repository.getFeature(i); |
| 60 | + const auto d = dist_func(query, base_feature, dist_func_param); |
| 61 | + if (d < min_dist) min_dist = d; |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + const uint64_t total_time_us = stopw.getElapsedTimeMicro(); |
| 66 | + const uint64_t time_per_query_us = total_time_us / query_count; |
| 67 | + |
| 68 | + log("Linear search baseline: {}us per query (total: {}ms for {} queries) with min distance {}\n", |
| 69 | + time_per_query_us, |
| 70 | + total_time_us / 1000, |
| 71 | + query_count, |
| 72 | + min_dist); |
| 73 | + |
| 74 | + return time_per_query_us; |
| 75 | +} |
| 76 | + |
| 77 | +static float test_approx_anns(const deglib::search::SearchGraph& graph, |
| 78 | + const std::vector<uint32_t>& entry_vertex_indices, |
| 79 | + const deglib::FeatureRepository& query_repository, |
| 80 | + const std::vector<std::vector<uint32_t>>& ground_truth, |
| 81 | + const float eps, |
| 82 | + const uint32_t k, |
| 83 | + const uint32_t test_size, |
| 84 | + const uint32_t threads, |
| 85 | + const deglib::graph::Filter* filter = nullptr) { |
| 86 | + auto corrects = std::vector<float>(threads); |
| 87 | + deglib::concurrent::parallel_for(0, test_size, threads, [&](size_t i, size_t thread_id) { |
| 88 | + auto query = reinterpret_cast<const std::byte*>(query_repository.getFeature(uint32_t(i))); |
| 89 | + auto result_queue = graph.search(entry_vertex_indices, query, eps, k, filter); |
| 90 | + |
| 91 | + if (result_queue.size() != k) { |
| 92 | + fmt::print(stderr, "ANNS with k={} got only {} results for query {}\n", k, result_queue.size(), i); |
| 93 | + abort(); |
| 94 | + } |
| 95 | + |
| 96 | + uint32_t correct = 0; |
| 97 | + const auto& gt = ground_truth[i]; |
| 98 | + while (result_queue.empty() == false) { |
| 99 | + const auto& result = result_queue.top(); |
| 100 | + const auto external_id = graph.getExternalLabel(result.getInternalIndex()); |
| 101 | + if (std::binary_search(gt.begin(), gt.end(), external_id)) correct++; |
| 102 | + result_queue.pop(); |
| 103 | + } |
| 104 | + |
| 105 | + corrects[thread_id] += correct; |
| 106 | + }); |
| 107 | + |
| 108 | + float total_correct = 0; |
| 109 | + for (size_t i = 0; i < threads; i++) total_correct += corrects[i]; |
| 110 | + return total_correct / (test_size * k); |
| 111 | +} |
| 112 | + |
| 113 | +static float test_approx_explore(const deglib::search::SearchGraph& graph, |
| 114 | + const std::vector<std::vector<uint32_t>>& entry_vertex_indices, |
| 115 | + const bool include_entry, |
| 116 | + const std::vector<std::vector<uint32_t>>& ground_truth, |
| 117 | + const uint32_t k, |
| 118 | + const uint32_t max_distance_count, |
| 119 | + const uint32_t threads, |
| 120 | + const deglib::graph::Filter* filter = nullptr) { |
| 121 | + auto corrects = std::vector<float>(threads); |
| 122 | + deglib::concurrent::parallel_for(0, entry_vertex_indices.size(), threads, [&](size_t i, size_t thread_id) { |
| 123 | + const auto entry_vertex_index = entry_vertex_indices[i][0]; |
| 124 | + auto result_queue = graph.explore(entry_vertex_index, k, include_entry, max_distance_count); |
| 125 | + |
| 126 | + if (result_queue.size() != k) { |
| 127 | + fmt::print(stderr, |
| 128 | + "Exploration with k={} got only {} results for query {} and max_distance_count {}\n", |
| 129 | + k, |
| 130 | + result_queue.size(), |
| 131 | + i, |
| 132 | + max_distance_count); |
| 133 | + abort(); |
| 134 | + } |
| 135 | + |
| 136 | + uint32_t correct = 0; |
| 137 | + const auto& gt = ground_truth[i]; |
| 138 | + while (result_queue.empty() == false) { |
| 139 | + const auto& result = result_queue.top(); |
| 140 | + const auto external_id = graph.getExternalLabel(result.getInternalIndex()); |
| 141 | + if (std::binary_search(gt.begin(), gt.end(), external_id)) correct++; |
| 142 | + result_queue.pop(); |
| 143 | + } |
| 144 | + |
| 145 | + corrects[thread_id] += correct; |
| 146 | + }); |
| 147 | + |
| 148 | + float total_correct = 0; |
| 149 | + for (size_t i = 0; i < threads; i++) total_correct += corrects[i]; |
| 150 | + return total_correct / (entry_vertex_indices.size() * k); |
| 151 | +} |
| 152 | + |
| 153 | +static std::vector<float> estimate_recall(const deglib::search::SearchGraph& graph, |
| 154 | + const deglib::FeatureRepository& query_repository, |
| 155 | + const std::vector<std::vector<uint32_t>>& answer, |
| 156 | + const uint32_t max_distance_count, |
| 157 | + const uint32_t k) { |
| 158 | + const auto entry_vertex_indices = std::vector<uint32_t>{graph.getInternalIndex(0)}; |
| 159 | + |
| 160 | + std::vector<float> recalls; |
| 161 | + std::vector<float> eps_parameter = {0.1f, 0.2f}; |
| 162 | + const uint32_t threads = std::thread::hardware_concurrency() / 2; |
| 163 | + |
| 164 | + for (float eps : eps_parameter) { |
| 165 | + std::atomic<size_t> total{0}; |
| 166 | + std::atomic<size_t> correct{0}; |
| 167 | + |
| 168 | + deglib::concurrent::parallel_for(0, query_repository.size(), threads, [&](size_t i, size_t thread_id) { |
| 169 | + auto query = reinterpret_cast<const std::byte*>(query_repository.getFeature(uint32_t(i))); |
| 170 | + auto result_queue = graph.search(entry_vertex_indices, query, eps, k, nullptr, max_distance_count); |
| 171 | + |
| 172 | + const auto& gt = answer[i]; |
| 173 | + total += result_queue.size(); |
| 174 | + |
| 175 | + size_t local_correct = 0; |
| 176 | + while (result_queue.empty() == false) { |
| 177 | + const auto internal_index = result_queue.top().getInternalIndex(); |
| 178 | + const auto external_id = graph.getExternalLabel(internal_index); |
| 179 | + if (std::binary_search(gt.begin(), gt.end(), external_id)) local_correct++; |
| 180 | + result_queue.pop(); |
| 181 | + } |
| 182 | + correct += local_correct; |
| 183 | + }); |
| 184 | + |
| 185 | + const auto precision = ((float)correct.load()) / total.load(); |
| 186 | + recalls.push_back(precision); |
| 187 | + } |
| 188 | + |
| 189 | + return recalls; |
| 190 | +} |
| 191 | + |
| 192 | +static void test_graph_anns(const deglib::search::SearchGraph& graph, |
| 193 | + const deglib::FeatureRepository& query_repository, |
| 194 | + const std::vector<std::vector<uint32_t>>& ground_truth, |
| 195 | + const uint32_t repeat, |
| 196 | + const uint32_t threads, |
| 197 | + const uint32_t k, |
| 198 | + const std::vector<float>& eps_parameter, |
| 199 | + const deglib::graph::Filter* filter = nullptr, |
| 200 | + const uint64_t linear_baseline_us = 0, |
| 201 | + const uint32_t abort_sample_size = 100) { |
| 202 | + const auto entry_vertex_indices = graph.getEntryVertexIndices(); |
| 203 | + |
| 204 | + std::vector<float> eps_parameter_sorted = eps_parameter; |
| 205 | + std::sort(eps_parameter_sorted.begin(), eps_parameter_sorted.end()); |
| 206 | + log("Compute TOP{} for eps {}\n", k, fmt::join(eps_parameter_sorted, ", ")); |
| 207 | + if (linear_baseline_us > 0) { |
| 208 | + log("Early abort enabled: baseline {}us/query, checking after {} queries\n", linear_baseline_us, abort_sample_size); |
| 209 | + } |
| 210 | + |
| 211 | + log("Internal seed id {} \n", entry_vertex_indices[0]); |
| 212 | + log("Actual memory usage: {} Mb\n", getCurrentRSS() / 1000000); |
| 213 | + log("Max memory usage: {} Mb\n", getPeakRSS() / 1000000); |
| 214 | + |
| 215 | + const auto test_size = uint32_t(query_repository.size()); |
| 216 | + for (float eps : eps_parameter_sorted) { |
| 217 | + if (linear_baseline_us > 0 && test_size > abort_sample_size) { |
| 218 | + const auto sample_size = std::min(abort_sample_size, test_size); |
| 219 | + StopW sample_stopw = StopW(); |
| 220 | + deglib::benchmark::test_approx_anns( |
| 221 | + graph, entry_vertex_indices, query_repository, ground_truth, eps, k, sample_size, threads, filter); |
| 222 | + const uint64_t sample_time_us = sample_stopw.getElapsedTimeMicro(); |
| 223 | + const uint64_t sample_time_per_query = sample_time_us / sample_size; |
| 224 | + |
| 225 | + if (sample_time_per_query > linear_baseline_us) { |
| 226 | + log("eps {:.3f} \t ABORTED ({}us/query > {}us baseline after {} queries)\n", |
| 227 | + eps, |
| 228 | + sample_time_per_query, |
| 229 | + linear_baseline_us, |
| 230 | + sample_size); |
| 231 | + return; |
| 232 | + } |
| 233 | + } |
| 234 | + |
| 235 | + StopW stopw = StopW(); |
| 236 | + float recall = 0; |
| 237 | + for (size_t i = 0; i < repeat; i++) |
| 238 | + recall = deglib::benchmark::test_approx_anns( |
| 239 | + graph, entry_vertex_indices, query_repository, ground_truth, eps, k, test_size, threads, filter); |
| 240 | + uint64_t search_time_us = stopw.getElapsedTimeMicro(); |
| 241 | + uint64_t time_us_per_query = (search_time_us / test_size) / repeat; |
| 242 | + |
| 243 | + log("eps {:.3f} \t recall {:.5f} \t time_us_per_query {:6}us \t search time: {:6}ms\n", |
| 244 | + eps, |
| 245 | + recall, |
| 246 | + time_us_per_query, |
| 247 | + search_time_us / 1000); |
| 248 | + if (recall > 0.997) { |
| 249 | + log("Reached recall > 0.997, stopping further tests.\n"); |
| 250 | + return; |
| 251 | + } |
| 252 | + } |
| 253 | +} |
| 254 | + |
| 255 | +static void test_graph_explore(const deglib::search::SearchGraph& graph, |
| 256 | + const std::vector<uint32_t>& entry_vertex_labels, |
| 257 | + const std::vector<std::vector<uint32_t>>& ground_truth, |
| 258 | + const bool include_entry, |
| 259 | + const uint32_t repeat, |
| 260 | + const uint32_t k, |
| 261 | + const uint32_t threads, |
| 262 | + const deglib::graph::Filter* filter = nullptr, |
| 263 | + const uint32_t explore_depth = 3, |
| 264 | + const uint64_t linear_baseline_us = 0, |
| 265 | + const uint32_t abort_sample_size = 100, |
| 266 | + const float recall_target = 0.997f) { |
| 267 | + if (entry_vertex_labels.size() != ground_truth.size()) { |
| 268 | + fmt::print(stderr, "Entry vertex count {} does not match ground truth count {}\n", entry_vertex_labels.size(), ground_truth.size()); |
| 269 | + abort(); |
| 270 | + } |
| 271 | + |
| 272 | + const uint32_t query_count = (uint32_t)entry_vertex_labels.size(); |
| 273 | + |
| 274 | + auto entry_vertex_indices = std::vector<std::vector<uint32_t>>(query_count); |
| 275 | + for (size_t i = 0; i < query_count; i++) { |
| 276 | + entry_vertex_indices[i].push_back(graph.getInternalIndex(entry_vertex_labels[i])); |
| 277 | + } |
| 278 | + |
| 279 | + if (linear_baseline_us > 0) { |
| 280 | + log("Early abort enabled: baseline {}us/query, checking after {} queries\n", linear_baseline_us, abort_sample_size); |
| 281 | + } |
| 282 | + |
| 283 | + log("Actual memory usage: {} Mb\n", getCurrentRSS() / 1000000); |
| 284 | + log("Max memory usage: {} Mb\n", getPeakRSS() / 1000000); |
| 285 | + |
| 286 | + uint32_t k_factor = 100; |
| 287 | + float last_recall = -1.0f; |
| 288 | + for (uint32_t f = 0; f <= explore_depth; f++, k_factor *= 10) { |
| 289 | + for (uint32_t i = (f == 0) ? 1 : 2; i < 11; i++) { |
| 290 | + const auto max_distance_count = ((f == 0) ? (k + k_factor * (i - 1)) : (k_factor * i)); |
| 291 | + |
| 292 | + if (linear_baseline_us > 0 && query_count > abort_sample_size) { |
| 293 | + const auto sample_size = std::min(abort_sample_size, query_count); |
| 294 | + |
| 295 | + auto sample_entry_indices = |
| 296 | + std::vector<std::vector<uint32_t>>(entry_vertex_indices.begin(), entry_vertex_indices.begin() + sample_size); |
| 297 | + auto sample_ground_truth = std::vector<std::vector<uint32_t>>(ground_truth.begin(), ground_truth.begin() + sample_size); |
| 298 | + StopW sample_stopw = StopW(); |
| 299 | + deglib::benchmark::test_approx_explore( |
| 300 | + graph, sample_entry_indices, include_entry, sample_ground_truth, k, max_distance_count, threads, filter); |
| 301 | + const uint64_t sample_time_us = sample_stopw.getElapsedTimeMicro(); |
| 302 | + const uint64_t sample_time_per_query = sample_time_us / sample_size; |
| 303 | + |
| 304 | + if (sample_time_per_query > linear_baseline_us) { |
| 305 | + log("max_distance_count {:5}, k {:4}, ABORTED ({}us/query > {}us baseline after {} queries)\n", |
| 306 | + max_distance_count, |
| 307 | + k, |
| 308 | + sample_time_per_query, |
| 309 | + linear_baseline_us, |
| 310 | + sample_size); |
| 311 | + return; |
| 312 | + } |
| 313 | + } |
| 314 | + |
| 315 | + StopW stopw = StopW(); |
| 316 | + float recall = 0; |
| 317 | + for (size_t r = 0; r < repeat; r++) |
| 318 | + recall = deglib::benchmark::test_approx_explore( |
| 319 | + graph, entry_vertex_indices, include_entry, ground_truth, k, max_distance_count, threads, filter); |
| 320 | + uint64_t search_time_us = stopw.getElapsedTimeMicro(); |
| 321 | + uint64_t time_us_per_query = search_time_us / (query_count * repeat); |
| 322 | + |
| 323 | + log("k {:5}, max_distance_count {:6}, recall {:.4f}, time_us_per_query {:6}\n", |
| 324 | + k, |
| 325 | + max_distance_count, |
| 326 | + recall, |
| 327 | + time_us_per_query); |
| 328 | + |
| 329 | + if (recall == last_recall) { |
| 330 | + log("Recall stabilized at {:.4f}, stopping exploration sweep\n", recall); |
| 331 | + return; |
| 332 | + } |
| 333 | + last_recall = recall; |
| 334 | + |
| 335 | + if (recall >= recall_target) { |
| 336 | + log("Recall target {:.3f} reached, stopping exploration sweep\n", recall_target); |
| 337 | + return; |
| 338 | + } |
| 339 | + } |
| 340 | + } |
| 341 | +} |
| 342 | + |
| 343 | +} // namespace deglib::benchmark |
0 commit comments