在图表中加快对邻居的迭代

时间:2017-10-15 12:25:07

标签: c++ c++11 optimization graph neighbours

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

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

对于标有N0的{​​{1}}个节点以及N-1的平均邻居数的图表,我将每个状态存储为{{1的整数和K中每个节点的邻居数。

为了存储邻居信息,我创建了另外两个向量:std::vector<int> states,按顺序存储与节点std::vector<int> number_of_neighbors,节点std::vector<int> neighbor_lists,...,节点{的邻居的节点{1}}和索引向量0,为每个节点存储1中第一个邻居的索引。

所以我总共有四个向量:

N

更新节点std::vector<int> index时,我会像这样访问其邻居:

neighbor_lists

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

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

2 个答案:

答案 0 :(得分:1)

了解N的数量级非常重要,因为如果它不高,你可以使用你知道编译时拓扑的事实,这样你就可以把已知维度的std::array中的数据(而不是std::vector s),使用尽可能小的类型(如果需要)保存堆栈内存,ad将其中一些定义为constexpr(除了states)。

所以,如果N不是太大(堆栈限制!),你可以定义

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

  • 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>,如果1​​6位足够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;在某些环境中它应该很高。

答案 1 :(得分:0)

目前,您正在访问每次迭代的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和来自的索引nbListstates中查找邻居状态。

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

通过节点线性读取具有额外的优势,即每个节点集的迭代只读取一次邻居列表,因此states保留在L1 $中的可能性将显着增加。

减小states的大小可以提高它进一步保持在L1 $的可能性,通过一点计算可以在每个字节中存储2个2位的状态,从而减小{{1}的大小到4KB。所以取决于多少&#34;东西&#34;你可能会有一个非常低的缓存未命中率。

但是如果你在节点中跳来跳去并且做了什么&#34;情况很快变得更糟,导致states几乎保证L2 $未命中,当前节点潜在L1 $未命中,K呼叫nbList。这可能导致减速10到50倍。

如果您在后一种情况下具有随机访问权限,则应考虑在邻居列表中存储该状态的额外副本,从而节省访问state K次的成本。你必须测量它是否更快。

关于在程序中嵌入数据,你可以获得一些不必访问向量的东西,在这种情况下,我估计它的增益不到1%。

In-lining和constexpr积极地使用你的编译器可以使你的计算机煮多年并回复&#34; 42&#34;作为该计划的最终结果。你必须找到一个中间地带。