加快对图形中邻居的迭代

Speed up Iteration Over Neighbors in a Graph

本文关键字:邻居 迭代 图形      更新时间:2023-10-16

我有一个静态图(拓扑不会随时间变化,并且在编译时是已知的),其中图中的每个节点都可以具有三种状态之一。然后,我模拟了一个动态,其中节点有随时间改变其状态的概率,而这个概率取决于其邻居的状态。随着图形变大,模拟开始变得非常慢,但是经过一些分析后,我发现大部分计算时间都花在迭代邻居列表上。

我能够通过更改用于访问图中邻居的数据结构来提高模拟的速度,但想知道是否有更好(更快)的方法可以做到这一点。 我目前的实现是这样的:

对于具有从0N-1标记的N个节点和K的平均邻居数的图,我将每个状态存储为std::vector<int> states中的整数,并将每个节点的邻居数存储在std::vector<int> number_of_neighbors中。

为了存储邻居信息,我又创建了两个向量:std::vector<int> neighbor_lists向量,它按顺序存储节点0的邻居节点,节点1,...,节点N,以及一个索引向量std::vector<int> index,它为每个节点存储其第一个邻居的索引neighbor_lists

所以我总共有四个向量:

printf( states.size()              );    // N
printf( number_of_neighbors.size() );    // N
printf( neighbor_lists.size()      );    // N * k
printf( index.size()               );    // N

更新节点i时,我像这样访问其邻居:

// access neighbors of node i:
for ( int s=0; s<number_of_neighbors[i]; s++ ) {
int neighbor_node = neighbor_lists[index[i] + s];
int state_of_neighbor = states[neighbor_node];
// use neighbor state for stuff...
}

总结一下我的问题:在固定的图结构中访问相邻节点是否有更快的实现?

目前,我已经在相当多的模拟时间内达到了 N = 5000,但如果可能的话,我的目标是 N ~ 15.000。

知道N的数量级很重要,因为,如果它不高,你可以利用你知道编译时拓扑的事实,这样你就可以将数据放在已知维度的std::arrays(而不是std::vectors),使用尽可能小的类型来(如有必要)节省堆栈内存, AD 将其中一些定义为constexpr(除states外的所有

)。因此,如果N不是太大(堆栈限制!),您可以定义

  • states作为std::array<std::uint_fast8_t, N>(8 位 3 状态就足够了)

  • number_of_neighbors作为constexpr std::array<std::uint_fast8_t, N>(如果最大邻居数小于 256,否则为更大的类型)

  • neighbor_list作为constexpr std::array<std::uint_fast16_t, M>(其中M是邻居数的已知总和),如果 16 位足以N;否则为更大的类型

  • index作为constexpr std::array<std::uint_fast16_t, N>,如果 16 位足以M;否则更大的类型

我认为(我希望)使用已知维度的数组constexpr(如果可能)编译器可以创建最快的代码。

关于更新代码...我是一个老 C 程序员,所以我习惯于尝试以现代编译器做得更好的方式优化代码,所以我不知道以下代码是否是一个好主意;无论如何,我会像这样编写代码

auto first = index[i];
auto top   = first + number_of_neighbors[i];
for ( auto s = first ; s < top ; ++s ) {
auto neighbor_node = neighbor_lists[s];
auto state_of_neighbor = states[neighbor_node];
// use neighbor state for stuff...
}

--编辑--

OP 指定

目前,我已经在相当多的模拟时间内达到了 N = 5000,但如果可能的话,我的目标是 N ~ 15.000。

所以 16 位应该足够了 - 对于neighbor_listindex的类型 - 并且

  • statesnumber_of_neighbors各约为 15 kB(使用 16 位变量时为 30 kB)

  • index约为 30 kB。

在我看来,这是堆栈变量的合理值。

问题可能是neighbor_list;如果邻居的中等数量很低,比如说10来固定一个数字,我们有M(邻居的总和)大约是150'000,所以neighbor_list大约是300 kB;不低,但对于某些环境来说是合理的。

如果中值很高(例如 100),则修复另一个数字--,neighbor_list变为大约 3 MB;在某些环境中,它应该是高。

目前,您正在访问每次迭代的 sum(K) 节点。这听起来还不错...直到您点击访问缓存。

对于少于 2^16 个节点,您只需要一个uint16_t来标识节点,但对于 K 个邻居,您将需要一个uint32_t来索引邻居列表。 如前所述,这 3 种状态可以存储在 2 位中。

所以有

// your nodes neighbours, N elements, 16K*4 bytes=64KB
// really the start of the next nodes neighbour as we start in zero.
std::vector<uint32_t> nbOffset;
// states of your nodes, N elements, 16K* 1 byte=16K
std::vector<uint8_t> states;
// list of all neighbour relations, 
// sum(K) > 2^16, sum(K) elements, sum(K)*2 byte (E.g. for average K=16, 16K*2*16=512KB
std::vector<uint16_t> nbList;

您的代码:

// access neighbors of node i:
for ( int s=0; s<number_of_neighbors[i]; s++ ) {
int neighbor_node = neighbor_lists[index[i] + s];
int state_of_neighbor = states[neighbor_node];
// use neighbor state for stuff...
}

将代码重写为

uint32_t curNb = 0;
for (auto curOffset : nbOffset) {
for (; curNb < curOffset; curNb++) {
int neighbor_node = nbList[curNb]; // done away with one indirection.
int state_of_neighbor = states[neighbor_node]; 
// use neighbor state for stuff...
} 
}

因此,要更新一个节点,您需要从states读取当前状态,读取nbOffset的偏移量,并使用该索引查找邻居列表nbList和来自nbList的索引以查找states中的邻居状态。

如果您线性运行列表,前 2 个很可能已经在 L1$ 中。如果您线性计算节点,则从每个节点的nbList读取第一个值可能为 L1$,否则它很可能导致 L1$ 和 L2$ 未命中,以下读取将是硬件预取的。

线性读取节点具有额外的优势,即每个相邻列表在节点集的每次迭代中仅读取一次,因此states停留在 L1$ 中的可能性将大大增加。

减小states的大小可以进一步提高它停留在L1$的机会,只需稍加计算,就可以在每个字节中存储4个2位的状态,从而将states的大小减小到4KB。因此,根据您执行的"内容"量,缓存未命中率可能非常低。

但是,如果您在节点中跳来跳去并执行"操作",情况很快就会变得更糟,导致几乎保证nbList的L2$未命中和当前节点的潜在L1$未命中以及state的K调用。这可能导致减速 10 到 50 倍。

如果您在后一种情况下使用随机访问,您应该考虑在邻居列表中存储状态的额外副本,从而节省访问statesK 次的成本。你必须衡量这是否更快。

关于在程序中内联数据,您将因不必访问向量而获得一点收益,在这种情况下,我估计它从中获得的收益不到 1%。

内联和 constexpr 积极地将您的计算机煮沸多年,并回复"42"作为程序的最终结果。你必须找到一个中间立场。