Параллельный обход дерева в 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;
)