I have tried to implement a bitwise adjacency matrix to represent a graph with a fixed number of vertices.
Instead of wastefully representing each connection with an integer, 64 connections are packed into a single
uint64_t, to improve memory and hopefully performance (via improved cache locality).Instead of
vector<vector<uint64_t>>, using a contiguousvector<uint64_t>, once again to improve cache locality.A user interface similar to that of an
vector<unordered_set<int>>adjacency list:adjmat[i]to access a rowadjmat[i].contains(j)to check edge existence betweeniandj.for(int neighbor : adjmat[i])neighbor iteration.
My main motivation is just to create a really fast, efficient representation for dense graphs. However, I am quite inexperienced, and since performance optimization is quite technical, I'd really like some feedback since I might be out of my depth.
Currently some concerns I have with my code include:
Is this actually a valid way to improve performance of an adjacency matrix? I am using memory much more efficiently, but at the expense of more arithmetic and bit-shifting operations - does that erode any potential gains?
I want to add familiar layers of abstraction (e.g. iterators) to replicate some functionality of
vector<unordered_set<int>>. However, since I am already operating at such a low-level, I'm worried that the overhead of an iterator offsets all potential gains - if so, how can I create that abstraction without sacrificing performance?Also, I can't figure out why I can't
constexprmy constructor, and if I can't, does that make all the otherconstexprmethods useless?
Of course, any other comments or thoughts are appreciated too. Thanks!
#include <vector>
#include <cstdint>
#include <iostream>
class BitAdjmat {
public:
// Abstraction for a "Row" of the BitAdjmat
class Row {
public:
/**
* This row iterator should only iterate through existing edges, e.g. "1" entries.
* I've settled for only using a read-only iterator (i.e. const_iterator), since
* there is no way to hold a reference to a single bit.
*/
class const_iterator {
public:
constexpr const_iterator(std::size_t index,
std::vector<uint64_t>::const_iterator start,
std::size_t end_index)
: index{index}, start{start}, end_index{end_index}
{
if (!exists()) {
seek_next();
}
}
/**
* Warning: This is special constructor that is optimized for constructing
* the end() iterator, and shouldn't be used for anything else.
*/
constexpr const_iterator(std::size_t end_index,
std::vector<uint64_t>::const_iterator start) noexcept
: index{end_index}, start{start}, end_index{end_index} {}
constexpr const_iterator &operator++() noexcept {
seek_next();
return *this;
}
constexpr std::size_t operator*() const noexcept { return index; }
constexpr bool operator==(const const_iterator &other) const noexcept {
return index == other.index && start == other.start;
}
constexpr bool operator!=(const const_iterator &other) const noexcept { return !(*this == other); }
private:
// Check if current neighbor pointed to by *(start+index/N) exists, i.e., if the bit returned is 1.
constexpr bool exists() const noexcept { return ((*(start + index / N)) >> (index % N)) & 1ULL; }
constexpr void seek_next() noexcept {
while (index != end_index) {
++index;
if (exists())
return;
}
}
private:
std::size_t index; // logical vertex index (not vector<uint64_t> index)
const std::vector<uint64_t>::const_iterator start;
const std::size_t end_index;
};
public:
constexpr Row(const BitAdjmat &parent, std::size_t row_index) noexcept
: start{parent.matrix.begin() + parent.num_uint64_per_row * row_index},
num_vertices{parent.num_vertices} {}
constexpr bool contains(std::size_t nb_index) const noexcept {
return ((*(start + nb_index / N)) >> (nb_index % N)) & 1ULL;
}
constexpr const_iterator begin() const noexcept { return const_iterator(0, start, num_vertices); }
constexpr const_iterator end() const noexcept { return const_iterator(num_vertices, start); }
private:
const std::vector<uint64_t>::const_iterator start;
const std::size_t num_vertices;
};
public:
BitAdjmat(std::size_t num_vertices)
: num_vertices{num_vertices},
num_uint64_per_row{num_vertices / N + (num_vertices % N != 0)},
matrix{std::vector<uint64_t>(num_vertices * num_uint64_per_row, 0)} {}
constexpr Row operator[](std::size_t row_index) noexcept { return Row(*this, row_index); }
constexpr const Row operator[](std::size_t row_index) const noexcept { return Row(*this, row_index); }
constexpr std::size_t size() const { return num_vertices; }
// Direct access to adjmat entry, where 'i' and 'j' are logical vertex indices.
constexpr bool get(std::size_t i, std::size_t j) const noexcept {
return (matrix[flat_index(i, j / N)] >> (j % N)) & 1ULL;
}
// Direct setting of adjmat entries, where 'i' and 'j' are logical vertex indices.
constexpr void set(std::size_t i, std::size_t j, bool val) noexcept {
std::size_t q = j / N;
std::size_t r = j % N;
matrix[flat_index(i, q)] = (matrix[flat_index(i, q)] & ~(1ULL << r)) | (static_cast<uint64_t>(val) << r);
}
private:
const std::size_t num_vertices;
const std::size_t num_uint64_per_row;
std::vector<uint64_t> matrix; // Flat container representating a 2D vector<vector<uint64_t>>.
constexpr static std::size_t N = 64;
private:
constexpr std::size_t flat_index(std::size_t i, std::size_t j) const { return i * num_uint64_per_row + j; }
};
std::size_tcorrectly, but notstd::uint64_t. Is there a reason for that? \$\endgroup\$