Skip to content

Commit 7ef71fc

Browse files
committed
perf(graph): intrusive list in VisitedListPool & multi-threaded search benchmarks
- Replaces std::deque<std::unique_ptr<VisitedList>> with an allocation-free IntrusiveList - Avoids dynamic heap reallocations during high-throughput graph traversal - Adds multi-threaded search scaling regression tests (HighDim/LowDim up to 8 threads) across SizeBoundedGraph, ReadOnlyGraph, and DynamicGraph
2 parents 7b86438 + a3c806d commit 7ef71fc

12 files changed

Lines changed: 510 additions & 28 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#pragma once
2+
3+
#include <utility>
4+
5+
namespace deglib::graph {
6+
7+
/*
8+
Usage:
9+
10+
```
11+
struct Entry {
12+
Entry* next_; // list hooks
13+
Entry* prev_; // list hooks
14+
// ... other members
15+
};
16+
17+
using List = IntrusiveList<Entry, &Entry::next_, &Entry::prev_>;
18+
```
19+
20+
Make sure entries added to the list have a stable address.
21+
22+
23+
Implementation adapted from
24+
https://github.com/facebookexperimental/libunifex/blob/main/include/unifex/detail/intrusive_list.hpp
25+
*/
26+
template <class T, T* T::*Next, T* T::*Prev>
27+
class IntrusiveList
28+
{
29+
private:
30+
T* head_{};
31+
T* tail_{};
32+
33+
public:
34+
IntrusiveList() = default;
35+
36+
IntrusiveList(const IntrusiveList&) = delete;
37+
38+
IntrusiveList(IntrusiveList&& other) noexcept
39+
: head_(std::exchange(other.head_, nullptr)), tail_(std::exchange(other.tail_, nullptr))
40+
{
41+
}
42+
43+
~IntrusiveList() = default;
44+
45+
IntrusiveList& operator=(const IntrusiveList&) = delete;
46+
IntrusiveList& operator=(IntrusiveList&&) = delete;
47+
48+
[[nodiscard]] bool empty() const noexcept { return head_ == nullptr; }
49+
50+
void push_back(T* item) noexcept
51+
{
52+
item->*Prev = tail_;
53+
item->*Next = nullptr;
54+
if (tail_ == nullptr)
55+
head_ = item;
56+
else
57+
tail_->*Next = item;
58+
tail_ = item;
59+
}
60+
61+
[[nodiscard]] T* pop_front() noexcept
62+
{
63+
T* item = head_;
64+
head_ = item->*Next;
65+
if (head_ != nullptr)
66+
head_->*Prev = nullptr;
67+
else
68+
tail_ = nullptr;
69+
return item;
70+
}
71+
};
72+
73+
} // namespace deglib::graph

cpp/deglib/include/deglib/graph/visited_list_pool.h

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
#pragma once
22

3+
#include "intrusive_list.h"
4+
35
#include <algorithm>
46
#include <mutex>
5-
#include <vector>
6-
#include <deque>
7+
#include <memory>
78
#include <cstdint>
89
#include <memory>
910

@@ -12,14 +13,20 @@
1213
*/
1314
namespace deglib::graph {
1415

16+
class VisitedListPool;
17+
1518
class VisitedList {
1619
private:
17-
uint16_t current_tag_{1};
20+
friend VisitedListPool;
21+
22+
VisitedList* next_;
23+
VisitedList* prev_;
1824
std::unique_ptr<uint16_t[]> slots_;
19-
unsigned int num_elements_;
25+
uint32_t num_elements_;
26+
uint16_t current_tag_{1};
2027

2128
public:
22-
explicit VisitedList(int numelements1) : slots_(std::make_unique<uint16_t[]>(numelements1)), num_elements_(numelements1) {}
29+
explicit VisitedList(uint32_t numelements1) : slots_(std::make_unique<uint16_t[]>(numelements1)), num_elements_(numelements1) {}
2330

2431
[[nodiscard]] auto* get_visited() const {
2532
return slots_.get();
@@ -32,34 +39,38 @@ class VisitedList {
3239
void reset() {
3340
++current_tag_;
3441
if (current_tag_ == 0) {
35-
std::fill_n(slots_.get(), num_elements_, 0);
42+
std::fill_n(slots_.get(), num_elements_, uint16_t{});
3643
++current_tag_;
3744
}
3845
}
3946
};
4047

4148
class VisitedListPool {
4249
private:
43-
using ListPtr = std::unique_ptr<VisitedList>;
44-
45-
std::deque<ListPtr> pool_;
50+
IntrusiveList<VisitedList, &VisitedList::next_, &VisitedList::prev_> pool_;
4651
std::mutex pool_guard_;
47-
int num_elements_;
52+
uint32_t num_elements_;
4853

4954
public:
50-
VisitedListPool(int initmaxpools, int numelements) : num_elements_(numelements) {
51-
for (int i = 0; i < initmaxpools; i++)
52-
pool_.push_front(std::make_unique<VisitedList>(numelements));
55+
VisitedListPool(uint32_t initmaxpools, uint32_t numelements) : num_elements_(numelements) {
56+
for (uint32_t i = 0; i < initmaxpools; i++)
57+
pool_.push_back(new VisitedList(numelements));
58+
}
59+
60+
~VisitedListPool() noexcept {
61+
while (!pool_.empty()) {
62+
delete pool_.pop_front();
63+
}
5364
}
5465

5566
class FreeVisitedList {
5667
private:
5768
friend VisitedListPool;
5869

5970
VisitedListPool& pool_;
60-
ListPtr list_;
71+
VisitedList& list_;
6172

62-
FreeVisitedList(VisitedListPool& pool, ListPtr list) : pool_(pool), list_(std::move(list)) {}
73+
FreeVisitedList(VisitedListPool& pool, VisitedList& list) : pool_(pool), list_(list) {}
6374

6475
public:
6576
FreeVisitedList(const FreeVisitedList& other) = delete;
@@ -68,36 +79,35 @@ class VisitedListPool {
6879
FreeVisitedList& operator=(FreeVisitedList&& other) = delete;
6980

7081
~FreeVisitedList() noexcept {
71-
pool_.releaseVisitedList(std::move(list_));
82+
pool_.releaseVisitedList(list_);
7283
}
7384

7485
auto operator->() const {
75-
return list_.get();
86+
return &list_;
7687
}
7788
};
7889

79-
FreeVisitedList getFreeVisitedList() {
80-
ListPtr rez = popVisitedList();
90+
[[nodiscard]] FreeVisitedList getFreeVisitedList() {
91+
auto rez = popVisitedList();
8192
if (rez) {
8293
rez->reset();
8394
} else {
84-
rez = std::make_unique<VisitedList>(num_elements_);
95+
rez = new VisitedList(num_elements_);
8596
}
86-
return {*this, std::move(rez)};
97+
return {*this, *rez};
8798
}
8899

89100
private:
90-
void releaseVisitedList(ListPtr vl) {
101+
void releaseVisitedList(VisitedList& vl) {
91102
std::unique_lock <std::mutex> lock(pool_guard_);
92-
pool_.push_back(std::move(vl));
103+
pool_.push_back(&vl);
93104
}
94105

95-
ListPtr popVisitedList() {
96-
ListPtr rez;
106+
VisitedList* popVisitedList() {
107+
VisitedList* rez{};
97108
std::unique_lock <std::mutex> lock(pool_guard_);
98109
if (!pool_.empty()) {
99-
rez = std::move(pool_.front());
100-
pool_.pop_front();
110+
rez = pool_.pop_front();
101111
}
102112
return rez;
103113
}

cpp/test/src/common/test_helpers.h

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,135 @@ inline static void run_regression_test(const char* name, deglib::distances::Metr
754754
}
755755
}
756756

757+
// ---------------------------------------------------------------------------
758+
// Multi-threaded Search Benchmark Helper
759+
// ---------------------------------------------------------------------------
760+
template <typename GraphType>
761+
inline void run_multithreaded_search_benchmark(
762+
const std::string& name,
763+
const GraphType& graph,
764+
const std::vector<float>& query_data,
765+
size_t query_count,
766+
size_t dim,
767+
uint32_t search_k,
768+
float search_eps,
769+
const std::vector<std::vector<uint32_t>>& gt_data,
770+
const std::vector<uint32_t>& thread_counts = {1, 2, 4, 8},
771+
size_t num_runs = 50)
772+
{
773+
const size_t feature_bytes = dim * sizeof(float);
774+
const std::byte* query_bytes = reinterpret_cast<const std::byte*>(query_data.data());
775+
776+
std::cout << "\n--- [" << name << "] Multi-Threaded Search Scaling ---" << std::endl;
777+
778+
double baseline_qps = 0.0;
779+
780+
for (uint32_t num_threads : thread_counts)
781+
{
782+
// Warmup
783+
{
784+
std::vector<std::thread> warmup_threads;
785+
warmup_threads.reserve(num_threads);
786+
for (uint32_t t = 0; t < num_threads; ++t)
787+
{
788+
warmup_threads.emplace_back([&, t]() {
789+
size_t q_start = (t * query_count) / num_threads;
790+
size_t q_end = ((t + 1) * query_count) / num_threads;
791+
for (size_t q = q_start; q < q_end; ++q)
792+
{
793+
const std::byte* q_ptr = query_bytes + q * feature_bytes;
794+
std::span<const float> q_span(reinterpret_cast<const float*>(q_ptr), dim);
795+
auto result = graph.search(q_span, search_k, search_eps, nullptr, 0);
796+
}
797+
});
798+
}
799+
for (auto& wt : warmup_threads) wt.join();
800+
}
801+
802+
// Measured benchmark runs: spawn threads once and run all benchmark iterations inside workers
803+
std::atomic<size_t> total_correct{0};
804+
std::vector<std::thread> workers;
805+
workers.reserve(num_threads);
806+
807+
auto t_start = std::chrono::high_resolution_clock::now();
808+
809+
for (uint32_t t = 0; t < num_threads; ++t)
810+
{
811+
workers.emplace_back([&, t]() {
812+
size_t local_correct = 0;
813+
size_t q_start = (t * query_count) / num_threads;
814+
size_t q_end = ((t + 1) * query_count) / num_threads;
815+
816+
for (size_t r = 0; r < num_runs; ++r)
817+
{
818+
for (size_t q = q_start; q < q_end; ++q)
819+
{
820+
const std::byte* q_ptr = query_bytes + q * feature_bytes;
821+
std::span<const float> q_span(reinterpret_cast<const float*>(q_ptr), dim);
822+
auto result = graph.search(q_span, search_k, search_eps, nullptr, 0);
823+
824+
if (r == 0)
825+
{
826+
std::unordered_set<uint32_t> gt_set;
827+
if (!gt_data.empty() && q < gt_data.size())
828+
{
829+
size_t eval_k = std::min(static_cast<size_t>(search_k), gt_data[q].size());
830+
for (size_t i = 0; i < eval_k; ++i)
831+
{
832+
gt_set.insert(gt_data[q][i]);
833+
}
834+
}
835+
836+
while (!result.empty())
837+
{
838+
auto top_item = result.top();
839+
result.pop();
840+
uint32_t ext_label = graph.getExternalLabel(top_item.getIdentifier());
841+
if (gt_set.count(ext_label))
842+
{
843+
local_correct++;
844+
}
845+
}
846+
}
847+
}
848+
}
849+
850+
total_correct += local_correct;
851+
});
852+
}
853+
854+
for (auto& worker : workers)
855+
{
856+
worker.join();
857+
}
858+
859+
auto t_end = std::chrono::high_resolution_clock::now();
860+
double duration_secs = std::chrono::duration<double>(t_end - t_start).count();
861+
size_t total_queries = query_count * num_runs;
862+
double avg_qps = static_cast<double>(total_queries) / duration_secs;
863+
double avg_recall = static_cast<double>(total_correct.load()) / static_cast<double>(query_count * search_k);
864+
865+
if (num_threads == 1 || baseline_qps == 0.0)
866+
{
867+
baseline_qps = avg_qps;
868+
std::cout << " Threads: " << num_threads
869+
<< " | QPS: " << std::fixed << std::setprecision(1) << avg_qps
870+
<< " | Recall: " << std::setprecision(3) << avg_recall
871+
<< " | Speedup: 1.00x (baseline)" << std::endl;
872+
}
873+
else
874+
{
875+
double speedup = avg_qps / baseline_qps;
876+
std::cout << " Threads: " << num_threads
877+
<< " | QPS: " << std::fixed << std::setprecision(1) << avg_qps
878+
<< " | Recall: " << std::setprecision(3) << avg_recall
879+
<< " | Speedup: " << std::setprecision(2) << speedup << "x" << std::endl;
880+
}
881+
882+
EXPECT_GE(avg_recall + 1e-5, 0.85);
883+
}
884+
}
885+
757886
// ---------------------------------------------------------------------------
758887
// Checksums for dataset determinism verification
759888
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)