Подтвердить что ты не робот

Почему DFS медленнее в одном дереве и быстрее в другом?

ОБНОВЛЕНИЕ: Оказывается, в парсере произошла ошибка, которая генерировала деревья. Подробнее в Final edit.

Пусть T - двоичное дерево, так что каждый внутренний node имеет ровно два ребенка. Для этого дерева мы хотим закодировать функцию, которая для каждого node v в T находит количество узлов в поддереве, определяемом v.

Пример

Ввод

введите описание изображения здесь

Требуемый вывод

введите описание изображения здесь

С красным я указываю числа, которые мы хотим вычислить. Узлы дерева будут храниться в массиве, назовем его TreeArray, выполнив макет предзаказа.

В приведенном выше примере TreeArray будет содержать следующие объекты:

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

A node дерева описывается следующей структурой:

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 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 выглядит так (почти как левая цепочка):

введите описание изображения здесь

и медленнее, когда T выглядит так (почти как правая цепочка):

введите описание изображения здесь

Следующие эксперименты проводились на процессоре Intel (R) Core (TM) i5-3470 с частотой 3,0 ГГц с 8 ГБ оперативной памяти, кеш-память L1 256 КБ, кеш второго уровня 1 МБ, кеш-память L3 6 МБ.

Каждая точка в графах является результатом следующего для цикла (параметры определяются осью):

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

введите описание изображения здесь

n соответствует общему числу узлов, а время измеряется в секундах. Как видно, ясно, что при возрастании n функция намного быстрее, когда дерево выглядит как целая цепь слева, хотя число узлов в обоих случаях одинаково.

Теперь попробуем найти, где узкое место. Я использовал PAPI library для подсчета интересных счетчиков оборудования.

Первый счетчик - это инструкции, сколько инструкций мы фактически тратим? Есть ли разница, когда деревья выглядят иначе?

введите описание изображения здесь

Разница незначительна. Похоже, что для больших входов левая цепочка требует меньше инструкций, но разница настолько мала, поэтому я думаю, что можно с уверенностью предположить, что оба они требуют одинакового количества инструкций.

Увидев, что мы сохранили дерево в хорошем предваритетном макете внутри TreeArray, имеет смысл увидеть, что происходит в кеше. К сожалению для кеша L1 мой компьютер не предоставляет никаких счетчиков, но у меня есть для L2 и L3.

Посмотрим на доступ к кешу L2. Доступ к кешу L2 происходит, когда мы получаем пропущенную кеш-память L1, так что это косвенный счетчик для пропусков L1.

введите описание изображения здесь

Как мы видим, правильное дерево требует меньше промахов L1, поэтому кажется, что он эффективно использует кеш.

введите описание изображения здесь

То же самое для пропусков L2, правильное дерево кажется более эффективным. Все еще ничего не говорит о том, почему правильные деревья растут так медленно. Посмотрите на L3.

введите описание изображения здесь

В 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 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 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 мы сохраняем дерево в новом формате.

Здесь - левое дерево с 10 ^ 5 листьями

Здесь - это правое дерево с листьями 10 ^ 5

Время выполнения: ~ 0.07 секунд для левых деревьев ~ 0.12 секунды для правильных деревьев

Я прошу прощения за длинный пост, но, учитывая, насколько сужен, кажется, проблема, я не мог найти лучшего способа описать это.

Заранее благодарю вас!

EDIT:

Это последующее редактирование после ответа MrSmith42. Я понимаю, что местность играет очень большую роль, но я не уверен, что понимаю, что это так.

Для двух приведенных выше деревьев рассмотрим, как мы получаем доступ к памяти с течением времени.

Для дерева слева:

введите описание изображения здесь

Для правильного дерева:

введите описание изображения здесь

Мне кажется, что в обоих случаях мы имеем локальные шаблоны доступа.

EDIT:

Вот сюжет о числе условных ветвей:

введите описание изображения здесь

Вот сюжет о количестве неверных предсказаний ветки:

введите описание изображения здесь

Здесь - левое дерево с листьями 10 ^ 6

Здесь - это правое дерево с листьями 10 ^ 6

ЗАВЕРШЕНИЕ:

Я хотел бы извиниться за то, что тратил все время, у синтаксического анализатора, который я использовал, был параметр для того, как "осталось" или "право", я хотел бы, чтобы мое дерево выглядело. Это было плавающее число, оно должно было быть близко к 0, чтобы оно оставалось и приближалось к 1, чтобы сделать это правильно. Однако, чтобы сделать его похожим на цепочку, он должен быть очень маленьким, например 0.000000001 или 0.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 листьями.

введите описание изображения здесь

Выглядит очень красиво, как было предсказано на диаграммах выше (я только забыл на приведенных выше диаграммах тот факт, что, когда мы находим размер node, мы также получаем доступ к параметру размера этого node, cur в исходном коде выше).

Посмотрите на левое дерево с 100 листьями.

введите описание изображения здесь

Выглядит так, как ожидалось. Что насчет 1000 листьев?

введите описание изображения здесь

Это определенно не ожидается. В верхнем правом углу есть небольшой треугольник. И причина в том, что дерево не похоже на левую цепочку, в конце концов, где-то в конце висит небольшое поддерево. Проблема становится еще больше, когда листья 10 ^ 4.

введите описание изображения здесь

Посмотрим, что происходит с правильными деревьями. Когда листья составляют 10:

введите описание изображения здесь

Выглядит неплохо, около 100 листьев?

введите описание изображения здесь

Выглядит хорошо. Вот почему я поставил под сомнение местность правильных деревьев, для меня оба казались, по крайней мере, теорией локальными. Теперь, если вы попытаетесь увеличить размер, произойдет что-то интересное:

Для 1000 листов:

введите описание изображения здесь

Для 10 ^ 4 листьев вещи становятся еще более грязными:

введите описание изображения здесь

Шаблоны доступа, созданные правильным парсером

Вместо использования этого общего анализатора я создал один для этого конкретного вопроса:

#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 листьями:

введите описание изображения здесь

в черной части мы переходим от низкого места к высокому месту, но расстояние между предыдущим низким и текущим низким невелико, то же самое для предыдущего максимума и текущего максимума. Следовательно, кеш должен быть достаточно умным, чтобы удерживать два блока: один для низких мест и один для высоких мест, что дает небольшое количество промахов в кеше.

Для правых деревьев с листьями 10 ^ 4:

введите описание изображения здесь

Оригинальные эксперименты снова. На этот раз я мог только попробовать до 10 ^ 5 листьев, потому что, как заметил Mystical, мы получим переполнение стека из-за высоты деревьев, чего не было в предыдущих экспериментах, так как высота была меньше той, ожидается.

Разумеется, они кажутся одинаковыми, однако кеш и ветвь не нужны. Правые деревья били левые деревья в предсказаниях ветвей, левые деревья били правильные деревья в кеше.

Возможно, мое использование PAPI было неправильным, выход из perf:

левые деревья:

введите описание изображения здесь

правые деревья:

введите описание изображения здесь

Возможно, я снова что-то испортил, и я прошу прощения за это. Я включил свою попытку здесь на случай, если кто-то захочет продолжить расследование.

4b9b3361

Ответ 1

ОБНОВЛЕНИЕ:

Я определяю количество доступного элемента в массиве во времени

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 элемент полученного текстового файла: введите описание изображения здесь

Вы можете видеть, что для левого дерева все элементы локально доступны, но для правильного существует неравномерность при доступе.

OLD:

Я попытался вычислить количество считываний памяти с помощью 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" в "правильном" случае больше, чем в левом

Ответ 2

Кэш-пропуски различаются из-за расположения узлов в нашей памяти. Если вы обращаетесь к узлам в порядке, в котором они находятся в memmory, вполне вероятно, что кеш уже загрузил их из RAM в кеш (потому что страницы кэша нагрузки (скорее всего, больше одного из ваших узлов)).

Если вы обращаетесь к узлам в случайном порядке (в перспективе в позицию в ОЗУ) или в обратном порядке, становится более вероятным, что кеш еще не загрузил их из ОЗУ.

Таким образом, разница не связана с структурой вашего дерева, а с положением древовидных узлов в вашей ОЗУ по сравнению с порядком, к которому вы хотите получить доступ.


EDIT: (после того, как был добавлен вопрос о доступе):

Как вы можете видеть на графике шаблона доступа:
На "левом дереве" доступ перескакивает с низких до высоких индексов примерно на половину доступа. Таким образом, вторая половина, вероятно, всегда приведет к промахам в кеше, поскольку расстояние растет и растет.
На "правильном дереве" вторая половина имеет как минимум 2 узла рядом друг с другом (в порядке доступа), а также следующие два с удачей иногда на одной странице кеша.