Опубликован: 23.04.2013 | Доступ: свободный | Студентов: 854 / 184 | Длительность: 12:54:00
Лекция 8:

Распараллеливание циклов. Класс Parallel

< Лекция 7 || Лекция 8: 123456 || Лекция 9 >
Аннотация: Одна из основных задач, возникающих при параллельных вычислениях, связана с распараллеливанием циклов. Рассмотрены проблемы распараллеливания и варианты решения.

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

Предположим, что цикл допускает распараллеливание. Насколько просто его организовать? Возникают ли какие-нибудь проблемы, связанные с распараллеливанием? Ответ напрашивается - возникают. Отметим несколько серьезных проблем, которые приходится решать в каждом конкретном случае:

  • Накладные расходы. Когда каждая итерация цикла выполняется в отдельном потоке, то расходы, связанные с привлечением потока (расходы памяти и времени) являются накладными расходами. Если доля накладных расходов мала в сравнении с выигрышем по времени, которое получается за счет параллельного выполнения, то ими можно пожертвовать. Но для "коротких" циклов, где число операций, выполняемых на каждой из итераций, мало, нужно прилагать особые усилия для уменьшения накладных расходов.
  • Балансировка нагрузки. Итерация итерации рознь. Может оказаться, что некоторые итерации выполняются дольше, чем остальные. В этом случае необходимо предпринимать специальные меры для балансировки нагрузки на используемые потоки.
  • Управление итерациями. При выполнении одной или нескольких итераций могут возникать условия, требующие завершения цикла. Причины досрочного завершения могут быть разными - достижение требуемого результата, обнаружение ситуации, при которой продолжение цикла должно быть прервано, возникновение исключительной ситуации, прерывающей выполнение итерации. Во всех этих случаях нужно разумным способом завершить уже выполняемые итерации и не порождать выполнение итераций, еще не начавших свое выполнение.

Рассмотрим эти проблемы более подробно и то, как класс Parallel облегчает их решение.

Класс Parallel

Класс Parallel это статический класс, у которого только три метода:

  • For - параллельная версия оператора for. Метод перегружен и имеет 12 реализаций.
  • ForEach - параллельная версия оператора foreach. Метод перегружен и имеет 20 реализаций.
  • Invoke - метод, позволяющий организовать распараллеливание задач. Метод имеет две реализации.

Метод Parallel.For

Простейшая и основная форма метода Parallel.For имеет следующий синтаксис:

public static ParallelLoopResult For(
  int fromInclusive,
  int toExclusive,
  Action<int> body
)

Первые два параметра задают нижнюю и верхнюю границы области изменения индекса. Нижняя граница включается в область, верхняя - исключается. Это соответствует общепринятой записи оператора for:

for(int i = 0; i < n; i++) {body;}

Параметр body - делегат класса Action - представляет метод, выполняющий тело цикла. Методу передается индекс той итерации, которую должен выполнить метод.

Метод Parallel.For представляет функцию, возвращающую результат. Анализ этого результата необходим при управлении итерациями, позволяя выяснить, как закончилась итерация. Подробнее об этом будет сказано чуть позже.

Можно ли всякий цикл с оператором for заменить циклом с методом Parallel.For, не будучи уверенным, что итерации независимы? К сожалению, нет. Чтобы это было возможным, необходим оптимизирующий компилятор, который мог бы определить, являются ли итерации цикла независимыми. Еще лучше, если бы компилятор мог выделить в цикле ту его часть, которая допускает распараллеливание, и представить цикл в виде двух частей - часть, допускающую параллельное выполнение, и часть, требующую последовательного выполнения. Такие компиляторы существуют. В частности оптимизирующим компилятором, выполняющим распараллеливание, является компилятор Intel для языка С++. Компилятор языка С#, также как и JIT - компилятор IL языка подобными оптимизациями не занимаются. Мы видели, что и чистку цикла эти компиляторы не выполняют.

При распараллеливании циклов на языке С# ответственность за корректное применение метода Parallel.For полностью лежит на программисте. Если применить этот метод к циклу, где итерации не являются независимыми, то цикл будет выполняться, итерации будут выполняться параллельно, время работы сократится, но из-за гонки данных, скорее всего, результаты будут неправильными.

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

class Program
    {
        const int n = 10000;
        static int[] x;
        static long S;
        static long T;
        delegate void VV();

Рассмотрим теперь метод, содержащий цикл и использующий введенные в проекте переменные. В цикле вычисляется значение переменной S, как сумма значений некоторой функции Fs(i), и рассчитываются элементы массива x, получающие значение другой функции - Fx(i):

static void Sample1()
        {
            S = 0;
            for (int i = 0; i < n; i++)
            {
                x[i] = Fx(i);
                S = S + Fs(i);
            }
        }

Итерации цикла не являются независимыми, поскольку склеиваются общей переменной S. Попробуем бездумно применить метод Parallel.For и посмотрим, что получится:

static void Sample1P()
        {
            S = 0; 
            Parallel.For(0, n, (i) =>
            {
                x[i] = Fx(i);
                S = S + Fs(i);
            });
        }

Как видите, синтаксически все просто. Для задания тела цикла использован анонимный метод с лямбда-оператором. Все изменения связаны только с заголовком цикла.

Функции Fx и Fs введены для того, чтобы имитировать длинные вычисления на каждой итерации. Вот как они выглядят:

static int Fx(int i)
        {
            //имитация длинных вычислений
            const int m = 100000;
            int add = 1, t = 0; 
            for (int k = 0; k < m; k++)
            {
                t += add;
                add *= -1;
            }
            return i * 10;
        }
        static int Fs(int i)
        {
            //имитация длинных вычислений
            const int m = 100000;
            int add = 1, t = 0;
            for (int k = 0; k < m; k++)
            {
                t += add;
                add *= -1;
            }
            return i;
        }

Добавим в наш проект функцию Print, выполняющую две разнородные задачи, - измерение времени и печать результатов. Функция измеряет время T выполнения метода, переданного функции в качестве параметра, после чего выводит на консоль результаты работы метода:

static void Print(VV par, string mes)
        {
            DateTime start, finish;
            start = DateTime.Now;
                par();
            finish = DateTime.Now;
            T = (finish - start).Ticks;
            Console.WriteLine(mes);
            Console.WriteLine("S = {0}, T = {1}",
                S, T);
        }

Запустим теперь на выполнение наш проект с функцией Main, позволяющей провести первый эксперимент:

static void Main(string[] args)
        {
            x = new int[n];
            Print(Sample1);
            Print(Sample1P);
        }

Приведу результаты выполнения первого теста:

Некорректное применение метода Parallel.For

Рис. 7.1. Некорректное применение метода Parallel.For

Последовательный цикл работает долго, но дает правильные результаты. Лобовое распараллеливание позволяет сократить время в пять раз, но радости от этого мало, поскольку результаты не верны.

Займемся сами оптимизацией нашего цикла с целью его дальнейшего распараллеливания. Простейшая оптимизация состоит в разделении цикла на две части - последовательную и параллельную:

static void Sample2P()
        {
            S = 0;
            for(int i = 0; i < n; i++)
                S = S + Fs(i);
            Parallel.For(0, n, (i) =>
            {
                x[i] = Fx(i);                
            });
        }

Добавим в Main одну строчку:

Print(Sample2P);

Посмотрим на результаты:

Корректное применение метода Parallel.For

Рис. 7.2. Корректное применение метода Parallel.For

Полученный выигрыш во времени не столь значителен, но результаты расчетов верны.

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

static void Sample3P()
        {
            int[] temp = new int[n];
            Parallel.For(0, n, (i) =>
            {
                x[i] = Fx(i);
                temp[i] = Fs(i);
            });
            S = 0;
            for (int i = 0; i < n; i++)
                S = S + temp[i];            
        }

Теперь основные расчеты ведутся параллельно. Последовательно выполняется только заключительный этап, ведущий суммирование. Каковы теперь будут результаты выполнения теста?

Оптимизация цикла

Рис. 7.3. Оптимизация цикла

Как видите, время уменьшилось в пять раз в сравнении с чисто последовательным вариантом и результаты верны. Метод Parallel.For в данном тесте справился со своей задачей и показал хорошие результаты.

Давайте посмотрим, что если вместо метода Parallel.For непосредственно использовать работу с потоками:

static void Sample4P()
        {
            int[] temp = new int[n];
            Thread[] threads = new Thread[n];
            //создаем потоки
            for (int i = 0; i < n; i++)
            {
                threads[i] = new Thread((object p) =>
               {
                   int k = (int)p;
                   x[k] = Fx(k);
                   temp[k] = Fs(k);
               });
            }
            //запускаем потоки
            for (int i = 0; i < n; i++)
                threads[i].Start(i);
            //Ждем завершения
            for (int i = 0; i < n; i++)
                threads[i].Join();
            //Продолжаем работу
            S = 0;
            for (int i = 0; i < n; i++)
                S = S + temp[i];
        }

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

Тест распараллеливания с потоками

Рис. 7.4. Тест распараллеливания с потоками

Непосредственная работа с потоками дает по времени приличный результат, хуже, чем вариант с Parallel.For, но не намного хуже. Накладные расходы относительно невелики. Давайте посмотрим, что дает непосредственная работа с объектами класса Task, также как и Parallel.For, использующих пул потоков:

/// <summary>
       /// Применяем задачи
       /// </summary>
        static void Sample5P()
        {
            int[] temp = new int[n];
            Task[] tasks = new Task[n];
            //создаем и запускаем задачи
            for (int i = 0; i < n; i++)
            {
               tasks[i] = new Task((object p) =>
                {
                    int k = (int)p;
                    x[k] = Fx(k);
                    temp[k] = Fs(k);
                }, i);
            }
            for (int i = 0; i < n; i++)
                tasks[i].Start();
            //Ждем завершения
            Task.WaitAll(tasks);
            //Продолжаем работу
            S = 0;
            for (int i = 0; i < n; i++)
                S = S + temp[i];
        }

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

Тест с использованием задач

Рис. 7.5. Тест с использованием задач

В нашем конкурсе тестов победил тест Parallel.For, показавший лучшие результаты, чем тесты, использующие механизмы потоков и задач. Давайте проведем еще один тест, перейдя к коротким вычислениям, уменьшив до 1 константу m в функциях Fx и Fs:

Результаты такого эксперимента вполне соответствуют ожиданию:

Результаты эксперимента без введения задержек на итерациях

Рис. 7.6. Результаты эксперимента без введения задержек на итерациях

Как видите, тест с потоками безнадежно проиграл всем остальным участникам эксперимента, поскольку накладные расходы намного превосходят время, требуемое для проведения вычислений. Алгоритм с Parallel.For и здесь оказался победителем.

Подводя итоги этой серии экспериментов, можно отметить, что метод Parallel.For является синтаксически наиболее простым и интуитивно понятным методом распараллеливания циклов. Это средство высокого уровня, не требующее обращения к низкоуровневым понятиям - задачи (task) или потока (thread). Понятно, что на С# программисте лежит ответственность за корректное использование метода только для тех циклов, где итерации независимы. Создание собственного массива потоков для распараллеливания цикла представляется в большинстве случаев неразумным решением. Оно особо чревато неприятными последствиями, когда конкурировать начинают несколько задач, каждая из которых создает свои потоки. В этих ситуациях накладные расходы могут быть неоправданно велики.

< Лекция 7 || Лекция 8: 123456 || Лекция 9 >
Алексей Рыжков
Алексей Рыжков

не хватает одного параметра:

static void Main(string[] args)
        {
            x = new int[n];
            Print(Sample1,"original");
            Print(Sample1P, "paralel");
            Console.Read();
        }

Никита Белов
Никита Белов

Выставил оценки курса и заданий, начал писать замечания. После нажатия кнопки "Enter" окно отзыва пропало, открыть его снова не могу. Кнопка "Удалить комментарий" в разделе "Мнения" не работает. Как мне отредактировать недописанный отзыв?