| Россия |
Типовые модели параллельных приложений
При инициализации объекта указывается число участников барьерной синхронизации и делегат, вызываемый в конце каждого этапа.
Barrier bar = new Barrier(3, (bar) =>
{
Console.WriteLine("Phase: {0}",
bar.CurrentPhaseNumber);
});
Action worker = () => {
Work1();
bar.SignalAndWait();
Work2();
bar.SignalAndWait();
Work3();
};
var w1 = worker; var w2 = worker; var w3 = worker;
Parallel.Invoke(w1, w2, w3);
Метод SignalAndWait сигнализирует о завершении работы данным участником и блокирует поток до завершения работы всех участников. Объект Barrier позволяет изменять число участников в процессе работы.
Рекурсивные алгоритмы относятся к моделям делегирования. В качестве примера рассмотрим алгоритм быстрой сортировки. Алгоритм рекурсивно разбивает диапазон чисел на два диапазона в соответствии с выбранным ведущим элементом – левый диапазон содержит только числа, меньшие или равные ведущему элементу; правый диапазон содержит числа, большие ведущего элемента. Распараллеливание алгоритма сводится к одновременному выполнению обработки левого и правого диапазона.
static void ParallelSort(T[] data, int startIndex, int endIndex,
IComparer<T> comparer,
int minBlockSize=10000) {
if (startIndex < endIndex) {
// мало элементов – выполняем последовательную сортировку
if (endIndex - startIndex < minBlockSize) {
// Последовательная сортировка
Array.Sort(data, startIndex,
endIndex - startIndex + 1, comparer);
} else {
// Определяем ведущий элемент
int pivotIndex = partitionBlock(data, startIndex,
endIndex, comparer);
// обрабатываем левую и правую часть
Action leftTask = () =>
{
ParallelSort(data, startIndex,
pivotIndex - 1, comparer,
depth + 1, maxDepth, minBlockSize);
});
Action rightTask = () =>
{
ParallelSort(data, pivotIndex + 1,
endIndex, comparer,
depth + 1, maxDepth, minBlockSize);
});
// wait for the tasks to complete
Parallel.Invoke(leftTask, rightTask);
}
}
}
// Осуществляем перераспределение элементов
static int partitionBlock(T[] data, int startIndex,
int endIndex, IComparer<T> comparer) {
// Ведущий элемент
T pivot = data[startIndex];
// Перемещаем ведущий элемент в конец массива
swapValues(data, startIndex, endIndex);
// индекс ведущего элемента
int storeIndex = startIndex;
// цикл по всем элементам массива
for (int i = startIndex; i < endIndex; i++) {
// ищем элементы меньшие или равные ведущему
if (comparer.Compare(data[i], pivot) <= 0) {
// перемещаем элемент и увеличиваем индекс
swapValues(data, i, storeIndex);
storeIndex++;
}
}
swapValues(data, storeIndex, endIndex);
return storeIndex;
}
// Обмен элементов
static void swapValues(T[] data,
int firstIndex, int secondIndex) {
T holder = data[firstIndex];
data[firstIndex] = data[secondIndex];
data[secondIndex] = holder;
}
static void Main(string[] args) {
// generate some random source data
Random rnd = new Random();
int[] sourceData = new int[5000000];
for (int i = 0; i < sourceData.Length; i++) {
sourceData[i] = rnd.Next(1, 100);
}
QuickSort(sourceData, new IntComparer());
}
Основная проблема рекурсивных алгоритмов заключается в снижении эффективности при большой глубине рекурсии. Для ограничения рекурсивного разбиения множества данных применяется пороговая величина MinBlock. Если число элементов в блоке незначительно, то выполняется нерекурсивная сортировка (пузырьковая или сортировка со вставками). Распараллеливание быстрой сортировки приводит к еще одному источнику накладных расходов – рекурсивное порождение задач, конкурирующих за рабочие потоки пула. При использовании пользовательских потоков (работа с объектами Thread) конкуренция будет фатальной – рекурсивное порождение потоков приводит к значительным накладным расходам. Для контроля степени параллелизма применяют несколько подходов. Самый простой способ заключается в контроле глубины рекурсии – если глубина рекурсии превышает некий порог, то выполняется последовательная быстрая сортировка.
static void ParallelSort(T[] data, int startIndex, int endIndex,
IComparer<T> comparer,
int minBlockSize=10000,
int depth = 0,
int MaxDepth)
{
// Последовательная сортировка
if (endIndex - startIndex < minBlockSize)
InsertionSort(data, startIndex, endIndex, comparer);
else
{
// Определяем ведущий элемент
int pivotIndex = partitionBlock(data, startIndex,
endIndex, comparer);
// обрабатчик левой части
Action leftTask = () =>
{
ParallelSort(data, startIndex,
pivotIndex - 1, comparer,
depth + 1, maxDepth, minBlockSize);
});
// обработчик правой части
Action rightTask = () =>
{
ParallelSort(data, pivotIndex + 1,
endIndex, comparer,
depth + 1, maxDepth, minBlockSize);
});
if(depth >= MaxDepth)
{
leftTask();
rightTask();
}
else
{
Parallel.Invoke(leftTask, rightTask);
}
}
}
Глубина рекурсии является простым критерием, но не оптимальным. При плохом выборе ведущего элемента, блоки будут неравномерными, и глубина рекурсии для обработки каждого блока будет различной. Если правая часть содержит мало элементов, то обработка правой части будет завершена достаточно быстро. Поэтому распараллеливание обработки левой части может осуществляться и при большей глубине, чем задано параметром MaxDepth. Реализовать "адаптивный" параллелизм можно с помощью разделяемого счетчика фактических выполняющихся параллельных вызовов. При параллельном запуске быстрой сортировки - счетчик увеличивается, при завершении параллельных вызовов – счетчик уменьшается. Изменения счетчика необходимо выполнять атомарно с помощью методов Interlocked.Increment, Interlocked.Decrement.
static int parallelCalls;
static void ParallelSort(T[] data, int startIndex, int endIndex,
IComparer<T> comparer,
int minBlockSize=10000)
{
// Последовательная сортировка
if (endIndex - startIndex < minBlockSize)
InsertionSort(data, startIndex, endIndex, comparer);
else
{
// Определяем ведущий элемент
int pivotIndex = partitionBlock(data, startIndex,
endIndex, comparer);
// обработчик левой части
Action leftTask = () =>
{
ParallelSort(data, startIndex,
pivotIndex - 1, comparer,
depth + 1, maxDepth, minBlockSize);
});
// обработчик правой части
Action rightTask = () =>
{
ParallelSort(data, pivotIndex + 1,
endIndex, comparer,
depth + 1, maxDepth, minBlockSize);
});
if (parallelCalls > MaxParallelCalls)
{
leftTask();
rightTask();
}
else
{
Interlocked.Increment(ref parallelCalls);
Parallel.Invoke(leftTask, rightTask);
Interlocked.Decrement(ref parallelCalls);
}
}
}
Максимальное число параллельных вызовов MaxParallelCalls можно выбирать в зависимости от числа ядер вычислительной системы:
MaxParallelCalls = System.Environment.ProcessorCount / 2;
Таким образом, для двуядерной системы разрешается один параллельный вызов двух методов.