Параллельный обход дерева в C#

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

мой текущий код выглядит так:

   public void Traverse(Node root)
    {
        var nodeQueue = new Queue<Node>();
        nodeQueue.Enqueue(root);
        while (nodeQueue.Count!=0)
        {
            var node = nodeQueue.Dequeue();
            if (node.Property = someValue) DoSomething(node);
            foreach (var node in node.Children)
            {
                nodeQueue.Enqueue(node);
            }
        }
    }

Я действительно надеялся, что параллель.У фореча была параллель.В то время как аналоговые. Я наткнулся на статью Стивена Туба о реализация параллельного в то время как с параллельным.По каждому элементу. Если читать его правильно, это все равно не сработает, потому что я мутирую очередь, которую я пытаюсь повторить.

Мне нужно использовать фабрику задач и рекурсию (и это рискованно?) ? или есть какое-то простое решение, которое я упускаю из виду?

изменить: @svick

дерево имеет чуть более 250 000 узлов. Максимальная глубина сейчас составляет 14 узлов, включая корень.

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

@Enigmativity:

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

вызовы узла.Детей можно считать атомарными.

DoSomething действительно один из нескольких делегатов, для некоторых дорогостоящих операций я, вероятно, соберу список моментальных снимков узлов и обработаю их вне обхода.

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

я использовал параллели.ForEach (узлы, Траверс) для каждого алгоритма обхода, где узлы содержали все ~250k узлов. Это смоделировало (вроде) много пользователей, одновременно запрашивающих много разных узлов.

00256ms ширина первый последовательный

00323ms Ширина первого последовательного с работой (я увеличил статический счетчик как "работа")

01495ms Киркс первый ответ

01143ms Svicks второй ответ

00000ms рекурсивный однопоточный не закончился после 60-х годов

00000ms ответ Enigmativity не закончился после 60-х годов

@Enigma, я думаю, что, возможно, я как-то испортил ваш alogrithm, потому что кажется, что он должен гораздо быстрее.

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

для одиночного обхода головы распараллеливание первого уровня имело только лучшую производительность. Но едва это число улучшилось, когда я добавил больше узлов ко второму уровню (2000 вместо 500).

5 ответов


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

public void Traverse(Node root)
{
    if (node.Property == someValue)
        DoSomething(node);

    var tasks = new List<Task>();

    foreach (var node in node.Children)
    {
        // tmp is necessary because of the way closures close over loop variables
        var tmp = node;
        tasks.Add(Task.Factory.StartNew(() => Traverse(tmp)));
    }

    Task.WaitAll(tasks.ToArray());
}

Task достаточно легкий, поэтому много из них работает достаточно хорошо. Но у них есть некоторые накладные расходы, поэтому делать что-то более сложное, например, иметь несколько задач, которые разделяют очередь, вероятно, будет быстрее. Если вы идете этим путем, не забывайте, что пустая очередь не означает, что вся работа сделана. Классы System.Collections.Concurrent пространство имен пригодится, если пойдешь этим путем.

EDIT: из-за формы дерева (корень имеет около 500 детей), обработка только первого уровня параллельно должна дать хорошую производительность:

public void Traverse(Node root, bool parallel = true)
{
    if (node.Property == someValue)
        DoSomething(node);

    if (parallel)
    {
        Parallel.ForEach(node.Children, node =>
        {
            Traverse(node, false);
        });
    }
    else
    {
        foreach (var node in node.Children)
        {
            Traverse(node, false);
        }
    }
}

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

я начал с идеей, что мне нужна функция, которая принимает узел в качестве параметра, создает задачу, которая выполняет DoSomething, рекурсивно вызывает СЕБЯ Для создания задач для всех дочерних узлов и, наконец, возвращает задачу, которая ждет всех внутренние задачи должны быть завершены.

вот это:

Func<Node, Task> createTask = null;
createTask = n =>
{
    var nt = Task.Factory.StartNew(() =>
    {
        if (n.Property == someValue)
            DoSomething(n);
    });
    var nts = (new [] { nt, })
        .Concat(n.Children.Select(cn => createTask(cn)))
        .ToArray();

    return Task.Factory.ContinueWhenAll(nts, ts => { });
};

все, что требуется, чтобы вызвать его и ждать завершения обхода, это:

createTask(root).Wait();

Я проверил это, создав дерево узлов с 500 детьми от корня с 14 уровнями, с 1 или 2 последующими детьми на узел. Это дало мне всего 319,501 узлов.

Я создал DoSomething метод, который выполнил некоторую работу -for (var i = 0; i < 100000 ; i++) { }; - а затем запустил вышеуказанный код и сравнил его с обработка одного и того же дерева последовательно.

параллельная версия заняла 5,151 МС. Последовательная версия 13,746 ms.

я провела тест, где я уменьшил количество узлов до 3,196 и увеличил время обработки DoSomething 100x. TPL очень умно возвращается к последовательному запуску, если его задачи выполняются быстро, поэтому удлинение времени обработки сделало код более параллельным.

теперь параллельная версия заняла 3,203 МС. Последовательный версия заняла 11,581 МС. И, если бы я только назвал createTask(root) функция, не дожидаясь ее завершения, заняла всего 126 МС. Это означает, что дерево проходит очень быстро, и тогда имеет смысл заблокировать дерево во время обхода и разблокировать его при обработке.

надеюсь, это поможет.


возможно, я что-то упускаю, но я не вижу необходимости while на всех. The while просто гарантирует, что вы повторяете каждый узел.

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

public void Traverse(Node root)
{         
    if (root.Property = someValue) DoSomething(node);    
    Parallel.ForEach<Node>(root.Children, node => Traverse(node));
} 

edit: конечно, альтернатива, если вы предпочитаете обрабатывать горизонтально, а не вертикально, и Ваша дорогая операция-DoSomething, - это сделать Traverse первый.

public IEnumerable<Node> Traverse(Node root)
{
    // return all the nodes on this level first, before recurring
    foreach (var node in root.Children)
    {
        if (node.Property == someValue)
            yield return node;
    }

    // next check children of each node
    foreach (var node in root.Children)
    {
        var children = Traverse(node);
        foreach (var child in children)
        {
            yield return child;
        }
    }
}

Parallel.ForEach<Node>(Traverse(n), n => DoSomething(n));

Если у вас есть p процессоры может быть, вы делаете параллельный.Для над root.Дети!--2--> С p разделы. Каждый из них будет выполнять традиционную однопоточную траверсу над поддеревьями, сравнивать и, а не DoSomething, запросил бы делегата DoSomething в параллельную очередь. Если распределение в основном случайное и сбалансированное и поскольку обход только делает обход / enqueue, это порция берет 1 / p е время. Кроме того, обход, вероятно, исчерпает себя перед всеми DoSomethings выполнить, так что вы могли бы p потребители (исполнители DoSomething) дает вам максимальное параллельное выполнение, предполагая, что все эти операции, являются независимыми.

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


возможно, использование списка или массива вместо очереди поможет. Также используйте другой список / массив для заполнения следующих узлов для посещения. Вы все равно не будете обрабатывать список, пока не закончите всю ширину. Что-то вроде этого:--2-->

List<Node> todoList = new List<Node>();
todoList.Add(node);
while (todoList.Count > 0)
{
    // we'll be adding next nodes to process to this list so it needs to be thread-safe
    // or just sync access to a non-threadsafe list
    // if you know approx how many nodes you expect, you can pre-size the list
    ThreadSafeList<Node> nextList = new ThreadSafeList<Node>();  

    //todoList is readonly/static so can cache Count in simple variable
    int maxIndex  =  todoList.Count-1;
    // process todoList in parallel
    Parallel.For(0, maxIndex, i =>
    {
        // if list reads are thread-safe then no need to sync, otherwise sync
        Node x = todoList[i];

        //process x;
        // e.g. do somehting, get childrenNodesToWorkOnNext, etc.

        // add any child nodes that need to be processed next
        // e.g. nextList.add(childrenNodesToWorkOnNext);
    });

   // done with parallel processing by here so use the next todo list
   todoList = nextList;
)