Нулевой и первый потоки получили в своё распоряжение по семь итераций, второй поток шесть. Так что работу поделили почти поровну.
После выхода из параллельной области частичные суммы сложили, да ещё к этой сумме добавили первоначальное значение 10:
S = 7 +7 +6 +10 = 30.
Так работает редукция в параллельном цикле for.
Рис. 4.24. Изменение локальных копий
Задание. Составьте программу (рис. 4.23) и изучите поведение локальных копий общей переменной при распараллеливании цикла с редукцией.
4.3. Вычислительная нагрузка
В этом разделе мы познакомимся с тем, как распределяется вычислительная нагрузка между виртуальными процессорами. Проще говоря, между ядрами процессора.
Наша конечная цель загрузить все ядра работой на 100%. Только тогда можно будет измерить эффективность распараллеливания.
4.3.1. Полная загрузка
Первый эксперимент полная загрузка процессора.
Чтобы обеспечить работой все вычислительные элементы процессора, сделаем побольше итераций в нашем цикле. А для этого придётся взять другой целый тип счётчика «длинное-длинное целое»:
long long.
Это восемь байт, или 8 * 8 = 64 бита. Максимальное значение соответствует 264. Для целого числа со знаком получится раза в два поменьше, но всё равно хорошо.
Текст программы с нашей «большой задачей» приведён на рис. 4.25.
Мы объявляем тип счётчика и суммы (строка 4).
Число итераций нашего цикла берём много, но не больше максимально возможного (строка 7).
Тело цикла прибавление единичек к сумме (строка 8).
Рис. 4.25. «Большая» задача
Компилируем программу и переходим в командную строку.
Задаём количество потоков, равное количеству ядер.
Открываем Диспетчер задач:
Task Manager.
Находим вкладку Быстродействие:
Performance.
Запускаем программу на выполнение и смотрим на загрузку ядер (рис. 4.26).
Каждое ядро загружено на 100%.
Кроме того, в левой части окна показан график общей загрузки процессора в целом тоже 100%.
Переходим в командное окно и нажимаем комбинацию клавиш для прерывания программы:
Ctrl + C.
Загрузка процессора снова падает почти до нуля.
Рис. 4.26. Полная загрузка всех ядер
Задание. Составьте программу (рис. 4.25) и проведите эксперимент с полной загрузкой всех ядер процессора (рис. 4.26).
4.3.2. Неполная загрузка
Проведём следующий эксперимент.
Уменьшим количество потоков до двух.
Запускаем программу и следим за загрузкой процессора (рис. 4.27).
Видим, что на этот раз все ядра «немного заняты» работой.
Мы запустили задачу на двух параллельных потоках. Однако, операционная система старается равномерно распределить вычислительную нагрузку между всеми ядрами.
Рис. 4.27. Частичная загрузка всех ядер
Задание. Проведите эксперимент с частичной загрузкой всех ядер процессора, задав число потоков меньше, чем число ядер.
4.3.3. Привязка к ядрам
Привяжем параллельные потоки к конкретным ядрам процессора. Добавим в нашу программу с большой задачей несколько строк (рис. 4.28).
Вставляем дополнительные команды в начале параллельной области, когда локальная копия S получает нулевое значение (строка 11).
Определяем номер текущего потока:
omp_get_num_thread ().
Затем формируем маску для привязки потока к ядру (строка 13):
Mask = pow (2, TNum).
Возводим два в степень, равную номеру потока. Номера потоков начинаются от нуля. Маска привязки к вычислительному ядру состоит из степеней двойки. Таким образом, мы привязываем каждый поток к своему ядру.
Например, нулевой поток привязываем к нулевому ядру. Маска равна
20 = 1.
Первый поток привязываем к первому ядру. Маска равна
21 = 2.
И так далее.
Затем в строке 14 задаём привязку к ядру с помощью следующей функции:
SetThreadAffinityMask.
Для контроля выводим данные на экран (строка 15).
В нашей программе появился новый параметр директивы parallel:
private (TNum, Mask).
Таким способом мы объявляем, что две переменные будут локальными внутри параллельных потоков. Это означает, что в каждом потоке будет своя переменная с указанным названием. Присвоение значения такой переменной в одном потоке никак не повлияет на значение той же самой переменной в другом потоке. В нашей программе это номер потока в группе и маска для привязки к ядру.
Рис. 4.28. Привязка потоков к ядрам
Компилируем.
Переходим в командное окно. Задаём два потока и запускаем программу.
Рассмотрим загрузку в Диспетчере задач (рис. 4.29).
Теперь нулевое и первое ядра загружены на 100%. Остальные ядра не участвуют в нашей работе.
Рис. 4.29. Два потока два ядра
Задание. Составьте программу (рис. 4.28) и запускайте её с разным количеством потоков. Обратите внимание на загрузку отдельных ядер.
Рассмотрим значения маски для разного количества потоков. Убедимся, что значения маски вычисляются правильно (рис. 4.30).
Рис. 4.30. Вычисление маски
Задание. Установите разные значения количества потоков и проверьте правильность вычисления маски.
4.4. Время вычислений
Мы рассмотрели организацию параллельных вычислений. Настало время заняться временем. Нас интересует время вычислений. По нему мы сможем оценить эффективность распараллеливания.
Время в компьютере измеряют с помощью системного таймера. Для обращения к таймеру есть ряд готовых функций.