我有一个静态图(拓扑不会随着时间的推移而改变,并且在编译时是已知的),其中图中的每个节点都可以具有三种状态之一。然后,我模拟一个动态,其中节点有可能随时间改变其状态,并且这个概率取决于其邻居的状态。随着图形变大,模拟开始变得非常缓慢,但经过一些分析后,我发现大部分计算时间花在迭代邻居列表上。
我能够通过更改用于访问图中邻居的数据结构来提高模拟的速度,但是想知道是否有更好(更快)的方法来实现它。 我目前的实现是这样的:
对于标有N
到0
的{{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。
答案 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>
,如果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_list
和index
中的类型 - 和
states
和number_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
和来自的索引nbList
在states
中查找邻居状态。
如果您在列表中线性运行,前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;作为该计划的最终结果。你必须找到一个中间地带。