Skip to content

Commit 5d2340b

Browse files
committed
feat(perf): add cache prefetch auto tuning to the graph
1 parent e5e2ad6 commit 5d2340b

14 files changed

Lines changed: 589 additions & 70 deletions

File tree

‎cpp/API.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ public:
141141
bool isMutable(); ///< Check if graph allows modifications
142142
InternalGraph& internal(); ///< Access underlying internal graph
143143

144+
// --- Traversal Tuning ---
145+
146+
/// Auto-tune traversal prefetch parameters (po, pl, nl) by timing traversal over sampled vertices.
147+
/// The graph self-samples its own stored features as queries; call once after construction.
148+
void optimize(size_t sample_count = 50, uint32_t k = 100, uint32_t ef = 200, uint32_t seed = 7);
149+
144150
// --- Conversions & Persistence ---
145151

146152
/// Convert to a compact, read-only graph (ReadOnlyGraph) optimized for search
@@ -333,6 +339,34 @@ vector<ResultSet> rerank(
333339
size_t num_threads = 0
334340
);
335341

342+
/// Stateful reranker holding base vectors for repeated exact-distance candidate reranking
343+
template <typename DataT>
344+
class Reranker {
345+
public:
346+
Reranker(FloatSpace space, const DataT* base_vectors, size_t num_base_vectors);
347+
348+
FloatSpace getSpace() const; ///< Distance metric and dimensionality
349+
const DataT* getBaseVectors() const; ///< Held base vectors
350+
size_t getNumBaseVectors() const; ///< Number of held base vectors
351+
int32_t getPo() const; ///< Rerank feature prefetch offset (0 = prefetch disabled)
352+
int32_t getPl() const; ///< Cache lines prefetched per feature vector (0 = whole vector)
353+
void setPo(int32_t po); ///< Set the prefetch offset; values <= 0 disable prefetching
354+
void setPl(int32_t pl); ///< Set the prefetch length; values <= 0 prefetch the whole vector
355+
void setPrefetch(int32_t po, int32_t pl); ///< Set both prefetch parameters at once
356+
357+
/// Rerank candidate indices for one query, returning the top-k as a ResultSet (max-heap)
358+
ResultSet rerank(span<const byte> query, span<const uint32_t> candidate_indices, uint32_t k) const;
359+
360+
/// Multi-threaded batch reranking of candidate indices for many queries
361+
vector<ResultSet> rerank(
362+
const void* queries, size_t num_queries, const uint32_t* base_candidates,
363+
size_t candidates_per_query, size_t k_top = 0, size_t num_threads = 0
364+
) const;
365+
366+
/// Auto-tune rerank prefetch parameters (po, pl) by timing reranking over sampled base vectors
367+
void optimize(size_t sample_count = 50, size_t num_candidates = 100, uint32_t k = 10);
368+
};
369+
336370
} // namespace deglib::search
337371
```
338372
@@ -512,6 +546,14 @@ public:
512546
513547
ResultSet search(span<float> query, uint32_t k, float eps = 0.0f, Filter* filter = nullptr, uint32_t max_dc = 0);
514548
ResultSet explore(uint32_t entry_index, uint32_t k, uint32_t max_dc = 0, float eps = 0.0f, bool include_entry = true, Filter* filter = nullptr);
549+
550+
// --- Traversal Prefetch Tuning ---
551+
int32_t getPo() const; ///< Feature prefetch offset (candidates ahead)
552+
int32_t getPl() const; ///< Feature prefetch look-ahead (cache lines)
553+
int32_t getNl() const; ///< Neighbor prefetch look-ahead
554+
void setPrefetch(int32_t po, int32_t pl, int32_t nl = 3);
555+
/// Auto-tune (po, pl, nl) by timing traversal over sampled vertices (self-sampled features).
556+
void optimize(size_t sample_count = 50, uint32_t k = 100, uint32_t ef = 200, uint32_t seed = 7);
515557
};
516558
517559
/// Abstract base interface for mutable graphs supporting vertex & edge updates

‎cpp/deglib/include/deglib/graph.h‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,20 @@ class DynamicExplorationGraph {
218218
void setNl(int32_t nl) { internal_graph_->setNl(nl); }
219219
void setPrefetch(int32_t po, int32_t pl, int32_t nl = 3) { internal_graph_->setPrefetch(po, pl, nl); }
220220

221+
/**
222+
* Auto-tune the graph's traversal prefetch parameters (po, pl, nl) by empirically timing
223+
* traversal over sampled vertices. The graph self-samples its own stored features as queries,
224+
* so no external query buffer is needed. Forwards to the internal graph's optimize().
225+
*
226+
* @param sample_count Number of vertices sampled as queries (capped at the graph size).
227+
* @param k Result count per traversal (the expected query operating point).
228+
* @param ef Beam width per traversal; clamped to at least k.
229+
* @param seed Random seed for query sampling.
230+
*/
231+
void optimize(size_t sample_count = 50, uint32_t k = 100, uint32_t ef = 200, uint32_t seed = 7) const {
232+
internal_graph_->optimize(sample_count, k, ef, seed);
233+
}
234+
221235
bool saveGraph(const std::string& path) const {
222236
const auto* mutable_graph = dynamic_cast<const deglib::graph::MutableGraph*>(internal_graph_);
223237
if (mutable_graph == nullptr) {

‎cpp/deglib/include/deglib/graph/internal_graph.h‎

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include "deglib/search/result_list.h"
88

99
#include <algorithm>
10+
#include <chrono>
1011
#include <array>
1112
#include <cstddef>
1213
#include <cstdint>
@@ -119,9 +120,11 @@ class InternalGraph {
119120

120121
protected:
121122
std::vector<uint32_t> entry_vertex_indices_{0};
122-
int32_t po_ = 8;
123-
int32_t pl_ = 3;
124-
int32_t nl_ = 3;
123+
// Prefetch tuning knobs: performance cache state, not logical identity, hence mutable so
124+
// optimize()/setPrefetch() can run through a const InternalGraph reference.
125+
mutable int32_t po_ = 8;
126+
mutable int32_t pl_ = 3;
127+
mutable int32_t nl_ = 3;
125128

126129
public:
127130
const std::vector<uint32_t>& getEntryVertexIndices() const { return entry_vertex_indices_; }
@@ -133,20 +136,83 @@ class InternalGraph {
133136
int32_t getPo() const noexcept { return po_; }
134137
int32_t getPl() const noexcept { return pl_; }
135138
int32_t getNl() const noexcept { return nl_; }
136-
void setPo(int32_t po) noexcept {
139+
void setPo(int32_t po) const noexcept {
137140
if (po > 0) po_ = po;
138141
}
139-
void setPl(int32_t pl) noexcept {
142+
void setPl(int32_t pl) const noexcept {
140143
if (pl > 0) pl_ = pl;
141144
}
142-
void setNl(int32_t nl) noexcept {
145+
void setNl(int32_t nl) const noexcept {
143146
if (nl > 0) nl_ = nl;
144147
}
145-
void setPrefetch(int32_t po, int32_t pl, int32_t nl = 3) noexcept {
148+
void setPrefetch(int32_t po, int32_t pl, int32_t nl = 3) const noexcept {
146149
if (po > 0) po_ = po;
147150
if (pl > 0) pl_ = pl;
148151
if (nl > 0) nl_ = nl;
149152
}
153+
154+
/**
155+
* Auto-tunes the traversal prefetch parameters (po, pl, nl) by empirically timing graph
156+
* traversal over sampled vertices. Mirrors Reranker::optimize: the graph self-samples its own
157+
* stored features as queries, so no external query buffer or quantization round-trip is needed
158+
* and the timing exercises the real traversal path (search_ef_intern).
159+
*
160+
* @param sample_count Number of vertices sampled as queries (capped at the graph size).
161+
* @param k Result count per traversal (the expected query operating point).
162+
* @param ef Beam width per traversal; clamped to at least k.
163+
* @param seed Random seed for query sampling.
164+
*/
165+
void optimize(size_t sample_count = 50, uint32_t k = 100, uint32_t ef = 200, uint32_t seed = 7) const {
166+
const uint32_t n_vertices = size();
167+
const size_t n_queries = std::min(sample_count, static_cast<size_t>(n_vertices));
168+
// Below one full neighborhood the traversal does no real work; the timing would be meaningless.
169+
if (n_queries == 0 || k == 0 || n_queries < getEdgesPerVertex()) return;
170+
ef = std::max(ef, k);
171+
172+
std::vector<uint32_t> sample_idx(n_queries);
173+
uint32_t rng = seed * 2654435761u + 1u;
174+
for (auto& idx : sample_idx) {
175+
rng ^= rng << 13;
176+
rng ^= rng >> 17;
177+
rng ^= rng << 5;
178+
idx = rng % n_vertices;
179+
}
180+
181+
const std::vector<int32_t> try_pos = {4, 8, 12, 16};
182+
const std::vector<int32_t> try_pls = {2, 3, 4};
183+
const std::vector<int32_t> try_nls = {2, 3, 4};
184+
185+
const std::vector<uint32_t>& entries = getEntryVertexIndices();
186+
int32_t best_po = po_;
187+
int32_t best_pl = pl_;
188+
int32_t best_nl = nl_;
189+
double best_time = std::numeric_limits<double>::max();
190+
191+
for (int32_t po : try_pos) {
192+
for (int32_t pl : try_pls) {
193+
for (int32_t nl : try_nls) {
194+
setPrefetch(po, pl, nl);
195+
196+
for (size_t i = 0; i < std::min<size_t>(3, n_queries); ++i) {
197+
search_ef_intern(entries, getFeatureVector(sample_idx[i]), k, ef);
198+
}
199+
const auto t_start = std::chrono::high_resolution_clock::now();
200+
for (size_t i = 0; i < n_queries; ++i) {
201+
search_ef_intern(entries, getFeatureVector(sample_idx[i]), k, ef);
202+
}
203+
const double dur = std::chrono::duration<double, std::micro>(std::chrono::high_resolution_clock::now() - t_start).count();
204+
205+
if (dur < best_time) {
206+
best_time = dur;
207+
best_po = po;
208+
best_pl = pl;
209+
best_nl = nl;
210+
}
211+
}
212+
}
213+
}
214+
setPrefetch(best_po, best_pl, best_nl);
215+
}
150216
/**
151217
* Perform a search but stops when the to_vertex was found.
152218
*/

‎cpp/deglib/include/deglib/search.h‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class Reranker {
3434
deglib::distances::FloatSpace space_;
3535
const DataT* base_vectors_ = nullptr;
3636
size_t num_base_vectors_ = 0;
37-
int32_t po_ = 0; // Prefetch lookahead offset for candidate feature vectors (0 disables prefetch).
37+
int32_t po_ = 8; // Prefetch lookahead offset for candidate feature vectors (0 disables prefetch).
3838
int32_t pl_ = 0; // Cache lines to prefetch per feature vector (0 = prefetch the full vector).
3939

4040
/**
@@ -169,11 +169,17 @@ class Reranker {
169169
size_t getNumBaseVectors() const noexcept { return num_base_vectors_; }
170170
int32_t getPo() const noexcept { return po_; }
171171
int32_t getPl() const noexcept { return pl_; }
172+
void setPo(int32_t po) noexcept { po_ = (po > 0) ? po : 0; }
173+
void setPl(int32_t pl) noexcept { pl_ = (pl > 0) ? pl : 0; }
174+
void setPrefetch(int32_t po, int32_t pl) noexcept {
175+
po_ = (po > 0) ? po : 0;
176+
pl_ = (pl > 0) ? pl : 0;
177+
}
172178

173179
/**
174180
* Auto-tunes the rerank prefetch parameters (po, pl) using empirical timing on sample queries.
175181
* The tuning reflects the real operating point: each query scores `num_candidates` candidates and
176-
* reduces them to the top `k`. po_ = 0 keeps the non-prefetch path.
182+
* reduces them to the top `k`. Prefetch is on by default (po = 8, pl = 0); tuning only replaces one good setting with another.
177183
*
178184
* @param sample_queries Pointer to contiguous sample queries of dimension dim.
179185
* @param n_queries Number of sample queries.

‎cpp/deglib/include/deglib/search/searcher.h‎

Lines changed: 4 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -467,64 +467,10 @@ class SearcherImpl : public SearcherBase {
467467
refiner_.optimize(50, 100, k);
468468
}
469469

470-
// 3. Prefetch auto-tuning for graph traversal. Needs a graph at least one neighborhood
471-
// wide; below that the traversal does no real work and the timing is meaningless.
472-
if (graph_->size() < graph_->getEdgesPerVertex()) return;
473-
474-
const size_t test_q_count = std::min(size_t(50), size_t(graph_->size()));
475-
const uint32_t dim = graph_->getFeatureSpace().dim();
476-
477-
// Synthetic float queries in the searcher's input space. The graph stores features in its
478-
// own (possibly quantized) feature space, so reinterpreting getFeatureVector() as float
479-
// would read out of bounds and produce meaningless values; the searcher quantizes these
480-
// queries before traversal, which is exactly the path being timed.
481-
std::vector<float> sample_queries(test_q_count * size_t(dim));
482-
uint32_t rng = seed * 2654435761u + 1u;
483-
for (auto& v : sample_queries) {
484-
rng ^= rng << 13;
485-
rng ^= rng >> 17;
486-
rng ^= rng << 5;
487-
v = (static_cast<float>(rng & 0xFFFFFF) / 8388608.0f) - 1.0f; // [-1, 1)
488-
}
489-
490-
const std::vector<int32_t> try_pos = {4, 8, 12, 16};
491-
const std::vector<int32_t> try_pls = {2, 3, 4};
492-
const std::vector<int32_t> try_nls = {2, 3, 4};
493-
494-
int32_t best_po = graph_->getPo();
495-
int32_t best_pl = graph_->getPl();
496-
int32_t best_nl = graph_->getNl();
497-
double best_time = std::numeric_limits<double>::max();
498-
std::vector<uint32_t> dummy_out(k);
499-
const uint32_t ef = std::max<uint32_t>(200, k);
500-
501-
for (int32_t po : try_pos) {
502-
for (int32_t pl : try_pls) {
503-
for (int32_t nl : try_nls) {
504-
const_cast<deglib::graph::InternalGraph*>(graph_)->setPrefetch(po, pl, nl);
505-
506-
for (size_t i = 0; i < std::min(size_t(3), test_q_count); ++i) {
507-
search_single_ef_typed(sample_queries.data() + i * size_t(dim), k, ef, 1.0f, dummy_out.data(), nullptr, true);
508-
}
509-
510-
auto t_start = std::chrono::high_resolution_clock::now();
511-
for (size_t i = 0; i < test_q_count; ++i) {
512-
search_single_ef_typed(sample_queries.data() + i * size_t(dim), k, ef, 1.0f, dummy_out.data(), nullptr, true);
513-
}
514-
auto t_end = std::chrono::high_resolution_clock::now();
515-
double dur = std::chrono::duration<double, std::micro>(t_end - t_start).count();
516-
517-
if (dur < best_time) {
518-
best_time = dur;
519-
best_po = po;
520-
best_pl = pl;
521-
best_nl = nl;
522-
}
523-
}
524-
}
525-
}
526-
527-
const_cast<deglib::graph::InternalGraph*>(graph_)->setPrefetch(best_po, best_pl, best_nl);
470+
// 3. Auto-tune the graph's traversal prefetch parameters. The graph self-samples its own
471+
// stored features as queries, so no external query buffer or quantization round-trip is
472+
// needed; it guards the too-small-graph case internally.
473+
graph_->optimize(50, k, std::max<uint32_t>(200, k), seed);
528474
}
529475

530476
template <typename QueryT>

0 commit comments

Comments
 (0)