为什么在一个树结构中DFS较慢,在另一个树结构中却较快?

29

更新:原来生成树的解析器存在错误。请参见最终编辑部分。

T 是一棵二叉树,其中每个内部节点恰好有两个子节点。对于这棵树,我们想编写一个函数,对于树中的每个节点 v,找到由 v 定义的子树中节点的数量。

示例

输入:

enter image description here

期望的输出

enter image description here

红色数字表示我们要计算的数字。树的节点将存储在一个数组中,我们称其为TreeArray,按照前序布局进行。

对于上面的示例,TreeArray将包含以下对象:

10, 11, 0, 12, 13, 2, 7, 3, 14, 1, 15, 16, 4, 8, 17, 18, 5, 9, 6

树的节点由以下结构描述:

struct tree_node{

    long long int id; //id of the node, randomly generated
    int numChildren; //number of children, it is 2 but for the leafs it's 0
    int size; //size of the subtree rooted at the current node,
    // what we want to compute

    int pos; //position in TreeArray where the node is stored
    int lpos; //position of the left child
    int rpos; //position of the right child

    tree_node(){
        id = -1;
        size = 1;
        pos = lpos = rpos = -1;
        numChildren = 0;
    }

};

计算所有 size 值的函数如下:

void testCache(int cur){

    if(treeArray[cur].numChildren == 0){
        treeArray[cur].size = 1;
        return;
    }

    testCache(treeArray[cur].lpos);
    testCache(treeArray[cur].rpos);

    treeArray[cur].size = treeArray[treeArray[cur].lpos].size + 
    treeArray[treeArray[cur].rpos].size + 1;

}

我想了解为什么这个函数在 T 看起来像这样(几乎像一个向左的链)时会更加快速

enter image description here

T看起来像这样(几乎像一个向右的链)时,速度会变慢:

enter image description here

以下实验是在Intel(R) Core(TM) i5-3470 CPU @ 3.20GHz,8 GB RAM,L1缓存256 KB,L2缓存1 MB,L3缓存6 MB上运行的。
图中的每个点都是以下for循环的结果(参数由轴定义):
for (int i = 0; i < 100; i++) {
        testCache(0);
}

enter image description here

n 对应节点的总数,时间以秒为单位。我们可以看到,很明显当树形状类似于向左的链时,随着n的增长,函数速度更快,尽管在两种情况下节点数量完全相同。

现在让我们尝试找出瓶颈所在。我使用了PAPI库来计算有趣的硬件计数器。

第一个计数器是指令,我们实际上花费了多少指令?当树的形状不同时是否存在差异?

enter image description here

这两者之间的区别不是很显著。对于大输入数据,向左的链路需要更少的指令,但差异非常小,因此我认为可以安全地假设它们都需要相同数量的指令。
既然我们已经在treeArray中以漂亮的预排序布局存储了树,那么查看缓存中正在发生的事情是有意义的。不幸的是,对于L1缓存,我的电脑没有提供任何计数器,但是我有L2和L3的计数器。
让我们来看看对L2缓存的访问。当我们在L1缓存中遇到未命中时,就会发生对L2缓存的访问,因此这也是L1未命中的间接计数器。

enter image description here

我们可以看到,向右走的树需要较少的L1缺失,因此它似乎有效地利用了缓存。

enter image description here

对于L2缺失,右向树似乎更有效。但仍然没有任何迹象表明为什么右向树如此缓慢。让我们看一下L3。

enter image description here

在L3缓存中,右转树的东西会爆炸。因此问题似乎出现在L3缓存中。不幸的是,我无法解释这种行为的原因。为什么右转树的东西会在L3缓存中混乱?以下是完整的代码和实验:
#include <iostream>
#include <fstream>
#define BILLION  1000000000LL

using namespace std;


/*
 *
 * Timing functions
 *
 */

timespec startT, endT;

void startTimer(){
    clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &startT);
}

double endTimer(){
    clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &endT);
    return endT.tv_sec * BILLION + endT.tv_nsec - (startT.tv_sec * BILLION + startT.tv_nsec);
}

/*
 *
 * tree node
 *
 */

//this struct is used for creating the first tree after reading it from the external file, for this we need left and child pointers

struct tree_node_temp{

    long long int id; //id of the node, randomly generated
    int numChildren; //number of children, it is 2 but for the leafs it's 0
    int size; //size of the subtree rooted at the current node
    tree_node_temp *leftChild;
    tree_node_temp *rightChild;

    tree_node_temp(){
        id = -1;
        size = 1;
        leftChild = nullptr;
        rightChild = nullptr;
        numChildren = 0;
    }

};

struct tree_node{

    long long int id; //id of the node, randomly generated
    int numChildren; //number of children, it is 2 but for the leafs it's 0
    int size; //size of the subtree rooted at the current node

    int pos; //position in TreeArray where the node is stored
    int lpos; //position of the left child
    int rpos; //position of the right child

    tree_node(){
        id = -1;
        pos = lpos = rpos = -1;
        numChildren = 0;
    }

};

/*
 *
 * Tree parser. The input is a file containing the tree in the newick format.
 *
 */

string treeNewickStr; //string storing the newick format of a tree that we read from a file
int treeCurSTRindex; //index to the current position we are in while reading the newick string
int treeNumLeafs; //number of leafs in current tree
tree_node ** treeArrayReferences; //stack of references to free node objects
tree_node *treeArray; //array of node objects
int treeStackReferencesTop; //the top index to the references stack
int curpos; //used to find pos,lpos and rpos when creating the pre order layout tree


//helper function for readNewick
tree_node_temp* readNewickHelper() {

    int i;
    if(treeCurSTRindex == treeNewickStr.size())
        return nullptr;

    tree_node_temp * leftChild;
    tree_node_temp * rightChild;

    if(treeNewickStr[treeCurSTRindex] == '('){
        //create a left child
        treeCurSTRindex++;
        leftChild = readNewickHelper();
    }

    if(treeNewickStr[treeCurSTRindex] == ','){
        //create a right child
        treeCurSTRindex++;
        rightChild = readNewickHelper();
    }

    if(treeNewickStr[treeCurSTRindex] == ')' || treeNewickStr[treeCurSTRindex] == ';'){
        treeCurSTRindex++;
        tree_node_temp * cur = new tree_node_temp();
        cur->numChildren = 2;
        cur->leftChild = leftChild;
        cur->rightChild = rightChild;
        cur->size = 1 + leftChild->size + rightChild->size;
        return cur;
    }

    //we are about to read a label, keep reading until we read a "," ")" or "(" (we assume that the newick string has the right format)
    i = 0;
    char treeLabel[20]; //buffer used for the label
    while(treeNewickStr[treeCurSTRindex]!=',' && treeNewickStr[treeCurSTRindex]!='(' && treeNewickStr[treeCurSTRindex]!=')'){
        treeLabel[i] = treeNewickStr[treeCurSTRindex];
        treeCurSTRindex++;
        i++;
    }

    treeLabel[i] = '\0';
    tree_node_temp * cur = new tree_node_temp();
    cur->numChildren = 0;
    cur->id = atoi(treeLabel)-1;
    treeNumLeafs++;

    return cur;
}

//create the pre order tree, curRoot in the first call points to the root of the first tree that was given to us by the parser
void treeInit(tree_node_temp * curRoot){

    tree_node * curFinalRoot = treeArrayReferences[curpos];

    curFinalRoot->pos = curpos;

    if(curRoot->numChildren == 0) {
        curFinalRoot->id = curRoot->id;
        return;
    }

    //add left child
    tree_node * cnode = treeArrayReferences[treeStackReferencesTop];
    curFinalRoot->lpos = curpos + 1;
    curpos = curpos + 1;
    treeStackReferencesTop++;
    cnode->id = curRoot->leftChild->id;
    treeInit(curRoot->leftChild);

    //add right child
    curFinalRoot->rpos = curpos + 1;
    curpos = curpos + 1;
    cnode = treeArrayReferences[treeStackReferencesTop];
    treeStackReferencesTop++;
    cnode->id = curRoot->rightChild->id;
    treeInit(curRoot->rightChild);

    curFinalRoot->id = curRoot->id;
    curFinalRoot->numChildren = 2;
    curFinalRoot->size = curRoot->size;

}

//the ids of the leafs are deteremined by the newick file, for the internal nodes we just incrementally give the id determined by the dfs traversal
void updateInternalNodeIDs(int cur){

    tree_node* curNode = treeArrayReferences[cur];

    if(curNode->numChildren == 0){
        return;
    }
    curNode->id = treeNumLeafs++;
    updateInternalNodeIDs(curNode->lpos);
    updateInternalNodeIDs(curNode->rpos);

}

//frees the memory of the first tree generated by the parser
void treeFreeMemory(tree_node_temp* cur){

    if(cur->numChildren == 0){
        delete cur;
        return;
    }
    treeFreeMemory(cur->leftChild);
    treeFreeMemory(cur->rightChild);

    delete cur;

}

//reads the tree stored in "file" under the newick format and creates it in the main memory. The output (what the function returns) is a pointer to the root of the tree.
//this tree is scattered anywhere in the memory.

tree_node* readNewick(string& file){

    treeCurSTRindex = -1;
    treeNewickStr = "";
    treeNumLeafs = 0;

    ifstream treeFin;

    treeFin.open(file, ios_base::in);
    //read the newick format of the tree and store it in a string
    treeFin>>treeNewickStr;
    //initialize index for reading the string
    treeCurSTRindex = 0;
    //create the tree in main memory
    tree_node_temp* root = readNewickHelper();

    //store the tree in an array following the pre order layout
    treeArray = new tree_node[root->size];
    treeArrayReferences = new tree_node*[root->size];
    int i;
    for(i=0;i<root->size;i++)
        treeArrayReferences[i] = &treeArray[i];
    treeStackReferencesTop = 0;

    tree_node* finalRoot = treeArrayReferences[treeStackReferencesTop];
    curpos = treeStackReferencesTop;
    treeStackReferencesTop++;
    finalRoot->id = root->id;
    treeInit(root);

    //update the internal node ids (the leaf ids are defined by the ids stored in the newick string)
    updateInternalNodeIDs(0);
    //close the file
    treeFin.close();

    //free the memory of initial tree
    treeFreeMemory(root);
    //return the pre order tree
    return finalRoot;

}

/*
 *
 *
 * DOT FORMAT OUTPUT --- BEGIN
 *
 *
 */

void treeBstPrintDotAux(tree_node* node, ofstream& treeFout) {

    if(node->numChildren == 0) return;

    treeFout<<"    "<<node->id<<" -> "<<treeArrayReferences[node->lpos]->id<<";\n";
    treeBstPrintDotAux(treeArrayReferences[node->lpos], treeFout);

    treeFout<<"    "<<node->id<<" -> "<<treeArrayReferences[node->rpos]->id<<";\n";
    treeBstPrintDotAux(treeArrayReferences[node->rpos], treeFout);

}

void treePrintDotHelper(tree_node* cur, ofstream& treeFout){
    treeFout<<"digraph BST {\n";
    treeFout<<"    node [fontname=\"Arial\"];\n";

    if(cur == nullptr){
        treeFout<<"\n";
    }
    else if(cur->numChildren == 0){
        treeFout<<"    "<<cur->id<<";\n";
    }
    else{
        treeBstPrintDotAux(cur, treeFout);
    }

    treeFout<<"}\n";
}

void treePrintDot(string& file, tree_node* root){

    ofstream treeFout;
    treeFout.open(file, ios_base::out);
    treePrintDotHelper(root, treeFout);
    treeFout.close();

}

/*
 *
 *
 * DOT FORMAT OUTPUT --- END
 *
 *
 */

/*
 * experiments
 *
 */

tree_node* T;
int n;

void testCache(int cur){

    if(treeArray[cur].numChildren == 0){
        treeArray[cur].size = 1;
        return;
    }

    testCache(treeArray[cur].lpos);
    testCache(treeArray[cur].rpos);

    treeArray[cur].size = treeArray[treeArray[cur].lpos].size + treeArray[treeArray[cur].rpos].size + 1;

}


int main(int argc, char* argv[]){

    string Tnewick = argv[1];
    T = readNewick(Tnewick);

    n = T->size;
    double tt;

    startTimer();
    for (int i = 0; i < 100; i++) {
        testCache(0);
    }

    tt = endTimer();
    cout << tt / BILLION << '\t' << T->size;
    cout<<endl;

    return 0;
}

通过输入以下命令进行编译:g++ -O3 -std=c++11 file.cpp 通过输入以下命令运行:./executable tree.txt。在 tree.txt 中,我们将树存储为Newick格式
这里是一个有10^5个叶子的向左的树(链接)
这里是一个有10^5个叶子的向右的树(链接)
我得到的运行时间为: 向左的树约为0.07秒 向右的树约为0.12秒
很抱歉内容较长,但考虑到问题似乎很狭窄,我找不到更好的描述方式。
提前感谢您!
编辑:
这是对MrSmith42回答后的跟进编辑。我了解到地域因素起着非常重要的作用,但我不确定是否在这种情况下也是如此。
对于上面的两棵示例树,让我们看看如何随时间访问内存。
对于向左走的树:

enter image description here

对于右向树:

enter image description here

对我来说,在这两种情况下,我们似乎都有本地访问模式。
编辑:
这里是关于条件分支数量的图表:

enter image description here

这里是有关分支预测错误数量的图表:

enter image description here

这里是一个有10^6个叶子节点的向左生长的树

这里是一个有10^6个叶子节点的向右生长的树

最终编辑:

我想为浪费大家的时间道歉,我使用的解析器有一个参数,可以使我的树看起来“左”或“右”。那是一个浮点数,它必须接近0才能向左移动,接近1才能向右移动。但是,要使它看起来像一条链,它必须非常小,例如0.0000000010.999999999。对于小输入,即使对于像0.0001这样的值,树也看起来像一条链。我认为这个数字已经足够小了,而且它也会给较大的树带来链式效果,但正如我将展示的那样,并非如此。如果您使用像0.000000001这样的数字,解析器由于浮点问题而停止工作。
vadikrobot的答案表明我们存在局部性问题。受他的实验启发,我决定将上面的访问模式图形推广到不仅仅是示例树中,而是在任何树中都能看到它的行为。
我修改了vadikrobot的代码,如下所示:
void testCache(int cur, FILE *f) {

    if(treeArray[cur].numChildren == 0){
        fprintf(f, "%d\t", tim++);
        fprintf (f, "%d\n", cur);
        treeArray[cur].size = 1;
        return;
    }

    fprintf(f, "%d\t", tim++);
    fprintf (f, "%d\n", cur);
    testCache(treeArray[cur].lpos, f);
    fprintf(f, "%d\t", tim++);
    fprintf (f, "%d\n", cur);
    testCache(treeArray[cur].rpos, f);
    fprintf(f, "%d\t", tim++);
    fprintf (f, "%d\n", cur);
    fprintf(f, "%d\t", tim++);
    fprintf (f, "%d\n", treeArray[cur].lpos);
    fprintf(f, "%d\t", tim++);
    fprintf (f, "%d\n", treeArray[cur].rpos);
    treeArray[cur].size = treeArray[treeArray[cur].lpos].size + 
    treeArray[treeArray[cur].rpos].size + 1;
}

使用错误解析器生成的访问模式

让我们看一个有10个叶子节点的左树。

enter image description here

看起来非常不错,与上面的图表预测一样(我只是忘记了在上面的图表中,当我们找到节点的大小时,还要访问该节点的大小参数,源代码中的cur)。

让我们来看一个有100个叶子节点的左树。

enter image description here

看起来正常。1000片叶子呢?

enter image description here

这绝对是出乎意料的。右上角有一个小三角形。原因是树看起来不像一个向左的链,末尾还有一个小子树。当叶子节点达到10^4时,问题变得更加严重。

enter image description here

让我们来看看正确树的情况。当叶子数量为10时:

enter image description here

看起来不错,那100个叶子呢?

enter image description here

看起来也不错。这就是我对正确树的本地性提出质疑的原因,对我来说两者都至少在理论上是本地的。现在,如果你尝试增加大小,会发生一些有趣的事情:

对于1000个叶子:

enter image description here

对于10^4个叶子,情况会变得更加混乱:

enter image description here

正确解析器生成的访问模式

与使用通用解析器不同,我为这个特定问题创建了一个解析器:

#include <iostream>
#include <fstream>

using namespace std;

int main(int argc, char* argv[]){

    if(argc!=4){
        cout<<"type ./executable n{number of leafs} type{l:left going, r:right going} outputFile"<<endl;
        return 0;
    }

    int i;

    int n = atoi(argv[1]);

    if(n <= 2){cout<<"leafs must be at least 3"<<endl; return 0;}

    char c = argv[2][0];

    ofstream fout;
    fout.open(argv[3], ios_base::out);

    if(c == 'r'){

        for(i=0;i<n-1;i++){

            fout<<"("<<i<<",";

        }
        fout<<i;
        for(i=0;i<n;i++){
            fout<<")";
        }
        fout<<";"<<endl;

    }
    else{

        for(i=0;i<n-1;i++){
            fout<<"(";
        }

        fout<<1<<","<<n<<")";

        for(i=n-1;i>1;i--){
            fout<<","<<i<<")";
        }
        fout<<";"<<endl;

    }

    fout.close();


return 0;
}

现在访问模式看起来如预期。

对于有10^4个叶子节点的左树:

enter image description here

在黑色部分,我们从低处到高处走,但是前一个低点和当前低点之间的距离很小,前一个高点和当前高点也是如此。因此,缓存必须足够智能,可以容纳两个块,一个用于低点,一个用于高点,从而减少缓存未命中率。
对于具有10^4个叶子节点的右树:

enter image description here

这个实验重做了一遍。这次我只试了最多10^5片叶子,因为正如Mysticial所指出的那样,由于树的高度,我们会遇到堆栈溢出问题。这在之前的实验中并不是问题,因为高度小于预期。

时间上它们似乎表现相同,但缓存和分支不同。右侧树在分支预测方面胜过左侧树,在缓存方面则输给左侧树。

也许我的PAPI使用方法有误,来自perf的输出:

左侧树:

enter image description here

正确的树:

enter image description here

我可能又搞砸了什么事情,对此我感到抱歉。我在这里提供了我的尝试,以防有人想要继续调查。


很抱歉,在编辑时我忘记在for循环之前放置startTimer()。现在运行时间已经改变,对于l.txt~0.7s,对于r.txt~0.12s。我使用dot函数和https://en.wikipedia.org/wiki/DOT_(graph_description_language)生成了这些图形。 - jsguy
此外,如果您的测试大小达到了200万个节点,我预计基准测试将在左右两棵树上溢出。 - Mysticial
我添加了两棵拥有一百万个叶子的树,以防万一。 - jsguy
@jsguy:使用-O3选项会激活-finline-functions选项,使得gcc将testCache内联到自身几次。函数的大小增加了几倍,但最大堆栈深度减少了一个因子。因此,只有更大的n才会达到堆栈限制。 - user4407569
你提供的三个文件是否也受到了你的错误影响,还是它们没有问题?我可能会稍后再看一下,但不想浪费时间在错误的数据上。 - user4407569
显示剩余6条评论
2个回答

2
缓存未命中的原因是节点在内存中的位置不同。如果您按照内存中节点的顺序访问它们,那么缓存很可能已经将它们从RAM加载到了缓存中(因为加载缓存页面(通常比一个节点大))。如果您按照随机顺序(相对于RAM中的位置)或反向顺序访问节点,则更有可能缓存尚未从RAM中加载它们。
因此,区别不在于树的结构,而在于内存中树节点的位置与您要访问它们的顺序之间的比较。
编辑:(在问题中添加访问模式后):
如您在访问模式图表上所看到的: 对于“左倾树”,在约一半的访问后,访问跳跃从低索引到高索引。因此,由于距离越来越远,第二半部分很可能始终导致缓存未命中。 对于“右倾树”,第二半部分至少有2个接近彼此的节点(按访问顺序),并且接下来的两个节点有时也在同一个缓存页面中。

谢谢您的回答,我添加了一个编辑作为跟进。我不确定我们是否不对右向树进行本地访问。除非我误解了testCache()如何进行内存访问。 - jsguy
通常,访问内存中相邻节点的高RAM地址更快,因为CPU中的缓存管理器针对此方向进行了优化。例如,程序的命令在RAM中按此顺序排列,如果没有跳转或循环,则按照此顺序执行。数据也经常是如此,例如视频、图像等都是按此顺序存储的。 - MrSmith42
对于左侧的树,如果我们访问 A[i] 然后是 A[j],其中 i 是低位,j 是高位,下一组访问将是 A[i-1]A[j+1]。虽然我可以看出这可能会导致缓存未命中,但是难道缓存不会同时保存来自 A[i]A[j] 的附近块,因此不会因为这个跳跃而产生缓存未命中吗?关于右侧的树,访问模式是 A[j]A[j+1]A[j-2]A[j-1]A[j-4]A[j-3] 等等。我不确定为什么缓存无法获取足够大的块以使这些读取有效。 - jsguy
@jsguy:如果你很幸运,两个“块”都在缓存中。但至少对于左侧部分,我们会向后访问RAM地址,这在大多数情况下是不好的。 - MrSmith42

2

更新:

我将数组中访问的元素数量随时间的变化绘制出来。

void testCache(int cur, FILE *f) {
   if(treeArray[cur].numChildren == 0){
       fprintf (f, "%d\n", cur);
       treeArray[cur].size = 1;
       return;
   }

   fprintf (f, "%d\n", cur);
   testCache(treeArray[cur].lpos, f);
   fprintf (f, "%d\n", cur);
   testCache(treeArray[cur].rpos, f);

   fprintf (f, "%d\n", treeArray[cur].lpos);
   fprintf (f, "%d\n", treeArray[cur].rpos);
   treeArray[cur].size = treeArray[treeArray[cur].lpos].size + treeArray[treeArray[cur].rpos].size + 1;
}

作为结果,我绘制了结果文本文件的第999990个元素: enter image description here 你可以看到,对于左边的树,所有元素都是本地访问的,但对于右边的树存在访问不均匀的情况。
旧文本:
我尝试使用valgrind计算内存读取次数。 对于右边的树。
valgrind --tool=callgrind --cache-sim ./a.out right
==11493== I   refs:      427,444,674
==11493== I1  misses:          2,288
==11493== LLi misses:          2,068
==11493== I1  miss rate:        0.00%
==11493== LLi miss rate:        0.00%
==11493== 
==11493== D   refs:      213,159,341  (144,095,416 rd + 69,063,925 wr)
==11493== D1  misses:     15,401,346  ( 12,737,497 rd +  2,663,849 wr)
==11493== LLd misses:        329,337  (      7,935 rd +    321,402 wr)
==11493== D1  miss rate:         7.2% (        8.8%   +        3.9%  )
==11493== LLd miss rate:         0.2% (        0.0%   +        0.5%  )
==11493== 
==11493== LL refs:        15,403,634  ( 12,739,785 rd +  2,663,849 wr)
==11493== LL misses:         331,405  (     10,003 rd +    321,402 wr)
==11493== LL miss rate:          0.1% (        0.0%   +        0.5%  )

对于左侧的部分

valgrind --tool=callgrind --cache-sim=yes ./a.out left

==11496== I   refs:      418,204,722
==11496== I1  misses:          2,327
==11496== LLi misses:          2,099
==11496== I1  miss rate:        0.00%
==11496== LLi miss rate:        0.00%
==11496== 
==11496== D   refs:      204,114,971  (135,076,947 rd + 69,038,024 wr)
==11496== D1  misses:     19,470,268  ( 12,661,123 rd +  6,809,145 wr)
==11496== LLd misses:        306,948  (      7,935 rd +    299,013 wr)
==11496== D1  miss rate:         9.5% (        9.4%   +        9.9%  )
==11496== LLd miss rate:         0.2% (        0.0%   +        0.4%  )
==11496== 
==11496== LL refs:        19,472,595  ( 12,663,450 rd +  6,809,145 wr)
==11496== LL misses:         309,047  (     10,034 rd +    299,013 wr)
==11496== LL miss rate:          0.0% (        0.0%   +        0.4%  )

如您所见,在右侧情况下,内存读取次数“rd”比左侧更多。


你是对的,不幸的是这让我意识到我误用了解析器的参数来创建这些树。基本上,我运行的所有实验都没有真正运行在我声称它们运行的树上...真是一团糟,我非常抱歉(我会在我的帖子中更新更多细节)。 - jsguy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接