缓存二叉树的局部性

时间:2015-07-08 01:06:48

标签: c caching

如果我有一棵如下的树

struct tree_t {
    //data
    tree_t *left;
    tree_t *right;
};

我想开始为叶子分配内存,有没有办法确保当我遍历树时,叶子被缓存?如果我使用的是malloc,那么我认为叶子会分散在堆中,每次尝试访问时都会出现缓存缺失。

5 个答案:

答案 0 :(得分:6)

其他人给出了正确的答案,一个固定大小的池(https://en.wikipedia.org/wiki/Memory_pool),但还有一些额外的警告需要更彻底的解释。使用内存池分配的叶子仍然可能或甚至可能具有低缓存命中率。在64字节边界上对齐的8 * n tree_t的块是理想的,尽管在n == 1024之上没有任何好处。

作为旁注,请查看Judy数组,这是一个缓存优化的树形数据结构(https://en.wikipedia.org/wiki/Judy_array)。

(简要地)查看缓存如何工作是有帮助的。高速缓存分为多组固定大小的行。通常,线路大小在L1中为64字节;英特尔和AMD已经使用64字节L1缓存15年,现代ARM处理器(如A15)也使用64字节线。关联性确定对应于一组的行数。当数据进入缓存时,某些函数会将地址哈希到集合中。数据可以存储在集合中的任何行中。例如,在双向组关联高速缓存中,任何给定地址都可以存储在两个可能的高速缓存行之一中。

最大化可缓存性涉及减少缓存行提取:
1.将数据组织到缓存行大小的块中 2.在高速缓存行边界上对齐块 3.在映射到不同缓存集的地址分配块 4.在同一缓存行中存储具有时间局部性的数据(即,在同一时间访问) 5.如果可能,减小数据大小以增加密度。

如果你不做(1),那么提取将带来附近的,可能无用的数据,减少你关心的数据的空间量。如果你不做(2),那么你的对象很可能跨越缓存行,需要两倍的提取。如果你不做(3),那么一些缓存集很可能未被充分利用,效果与(1)类似。如果您不做(4),那么即使您最大限度地提高了缓存利用率,在提取时,提取的大部分数据也不会有用在数据变得有用之前,这条线可能会被逐出。 (5)通过将它们打包到更小的空间中来增加适合缓存的对象的数量。例如,如果你可以保证你的叶子少于2 ^ 32,那么你可以将一个uint32_t索引存储到tree_t []数组而不是指针中,这在64位平台上有100%的改进。

注意: malloc()通常会返回8字节或16字节对齐的块,这些块不合适;在GCC上使用posix_memalign()或在MSVC上使用_aligned_malloc()。

在你的情况下,你正在遍历一棵树,大概是一个有序的遍历。除非您的数据集适合缓存,否则叶子可能会均匀分布,并且ergo不太可能在同一缓存行中具有节点的时间局部性。在这种情况下,您可以做的最好的事情是通过分配缓存行大小和缓存行对齐的块来确保您的对象不跨越缓存行。

我在64字节高速缓存行和4字节指针的保守假设下选择了8 * n tree_t的块,这导致8字节tree_t和64/8 = 8 tree_t / line 。 n == 1024的上限是由于某个特定的x86 CPU(此时它逃脱了我)忽略了地址的第18位以便选择一个集合。

答案 1 :(得分:1)

可能可以在精选平台上提高缓存命中率 - 但当然没有什么可以保证从运行到运行的一致成功。

但是,让我们尝试一些想法:

  1. 创建tree_alloc()tree_free(),在一个组中分配/管理多个struct tree_t,在第一次调用时说256,然后将它们分配给接下来的255个分配。这会使随机分配/免费调用复杂化,但如果树很大并且其增长/收缩均匀,则可能值得付出努力。

  2. tree_t变小。使数据成为指针。

    struct tree_t {
      data_T *data
      tree_t *left;
      tree_t *right;
     };
    
  3. 糟糕! GTG - 将制作此维基

答案 2 :(得分:0)

malloc无法保证内存的分配位置。如果您希望并置数据以利用缓存局部性,则可以选择分配结构数组,然后从该数组(即对象池)进行分配。这基本上就像编写自己的内存分配器一样,除非它被大大简化为每个元素的大小相同。如果您知道所需的最大元素数量,那么它也会大大简化,因此您无需添加增加内存池大小的功能。#34;。如果您的分配/自由函数由不同的线程访问,您还必须考虑线程安全性。还有许多其他事情要考虑,这只是一些。

注意:就像其他人在评论中所说的那样,过早优化通常不值得,或者更糟糕的反效果,但如果你想尝试这是一种方式。

Here是关于对象池的有用链接

答案 3 :(得分:0)

将您的数据结构逻辑与内存分配逻辑分开,并且在优化代码时不应该有任何问题。

例如,您的add函数不应包含malloc,也不应包含任何free。想想sprintf如何运作;第一个参数是指向将写入字符串的位置的指针。因此,让add函数看起来像这样:

int add(struct tree *destination, struct tree *source) {
    // XXX: Add source into destination
}

...并在致电source之前分配add 。这样,您可以选择自动存储持续时间(例如struct tree foo[128];,作为一个数组,应该很好并且缓存友好),如果这适用于您,或者您可以选择使用malloc分配一个节点一个时间(不缓存友好),或者您可以选择使用malloc来分配大型节点组(应该再次缓存友好)...这为您提供了优化空间,是吗?

只有在您确定代码太慢并且您的探查器告诉您为什么您的代码很慢时,您才应该进行优化。

答案 4 :(得分:0)

此代码并未举例说明世界上最好的软件工程实践,而是满足您的需求:

tree_t *garbage = NULL;

tree_t *alloc_tree_t() {
    if (garbage == NULL) {
        tree_t *nodearr = malloc(sizeof(tree_t)*1024);
        for(int i = 0; i < 1023; i++) {
            nodearr[i]->left = &(nodearr[i+1]);
        }
        garbage = &(nodearr[0]);
    }
    tree_t *tmp = garbage;
    garbage = tmp->left;
    tmp->left = tmp->right = NULL;
    return tmp;
}

void free_tree_t(tree_t *p) {
    p->left = garbage;
    garbage = p;
    p->right = NULL;
}