【问题标题】:Speed up Iteration Over Neighbors in a Graph加速图中邻居的迭代
【发布时间】:2017-10-15 12:25:06
【问题描述】:

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

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

对于具有从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的数量级是...?
  • 一些 GPU 函数的迭代速度比 CPU 快。但我从来没有看过如何在 C++ 中做到这一点。我刚刚在课程中看到使用 pragma 是可能的。
  • 用 N (~1.5e4) 的大小更新了问题。我有 32GB 的可用内存,所以我可以估计我可以声明的数组有多大。谢谢。
  • 节点多久改变一次状态?一个节点有多少个邻居(平均和最大)?如果更改很少,您可以存储有关邻居的统计信息并在节点更改时更新所有邻居,而不是遍历它们来获取这些统计信息。

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


【解决方案1】:

了解N 的数量级很重要,因为如果它不是很高,您可以使用您知道拓扑的编译时间这一事实,这样您就可以将数据放入已知的std::arrays尺寸(而不是std::vectors),使用尽可能小的类型来(如果需要)节省堆栈内存,将其中一些定义为constexpr(除了states)。

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

  • states 作为std::array&lt;std::uint_fast8_t, N&gt;(3 个状态的 8 位就足够了)

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

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

  • index 作为constexpr std::array&lt;std::uint_fast16_t, N&gt;,如果 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;在某些环境中,它应该很高。

【讨论】:

  • 您也可以将数组放在一个结构中,以强制它们在内存中相邻,并可能提高局部性。 (尽管如果它们都具有静态存储持续时间并且彼此相邻定义,那么这可能无论如何都会发生。)
  • 我会尝试你的建议,然后更新这个帖子,谢谢。
  • 是否可以使用存储在文件中的值来声明 constexpr?还是我需要将 constexpr 数组值直接复制粘贴到源代码中?
  • @KevinLiu - 你的意思是编译时初始化一个 constexpr 变量从文件中读取值吗?不;据我所知是不可能的。我能想象的最好的是第一个 C++(或 gawk,或 shell 脚本)程序,它读取(运行时)文件并直接在源代码中创建(输出)第二个 C++11 程序,其中包含数组值。没有什么是一个好的 makefile 脚本无法管理的。
  • @KevinLiu - 考虑到N == 15000,答案得到了改进。
【解决方案2】:

目前您正在为每次迭代访问 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 读取偏移量并使用该索引查找邻居列表nbListnbList 的索引进行查找states 中的邻居状态。

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

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

减小states 的大小可以进一步提高它停留在L1$ 中的机会,稍加计算就可以在每个字节中存储4 个2 位的状态,从而将states 的大小减小到4KB。因此,根据你做了多少“东西”,你的缓存未命中率可能会非常低。

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

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

关于在程序中内联数据,您不必访问向量会有所收获,在这种情况下,我估计它的收益不到 1%。

内联和 constexpr 激进的编译器会使您的计算机沸腾多年,并回复“42”作为程序的最终结果。你必须找到一个中间立场。

【讨论】:

  • 我知道不建议这样做,但我会暂时在此感谢您的回答。当我开始测试建议时,我会更新问题。谢谢!
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-01-29
  • 2021-12-09
  • 2019-06-05
  • 1970-01-01
  • 2013-01-21
相关资源
最近更新 更多