
- •Параллельные вычисления.
- •История развития многопроцессорной вычислительной техники.
- •Классификация вычислительных систем.
- •Обзор архитектур многопроцессорных систем.
- •Одновременная многопотоковость.
- •Многоядерность.
- •Системы с массовым параллелизмом.
- •Кластерные системы.
- •Ускорители вычислений.
- •Замеры времени на видеокарте.
- •Типы памяти cuda.
- •Работа с константной памятью.
- •Работа с глобальной памятью.
- •Оптимизация работы с глобальной памятью.
- •Разделяемая память и её использование.
- •Реализация на cuda базовых операций над массивами.
- •Принципы программирования на системах с распределенной памятью.
- •Типовые схемы коммуникации в многопроцессорных вычислительных системах.
Разделяемая память и её использование.
Каждый мультипроцессор содержит 16 Кб разделяемой памяти. Эта память поровну делится между всеми блоками сетки. Разделяемая память используется для передачи параметров при запуске ядра. Поэтому желательно избегать передачи большого объема данных. В случае большого объема можно использовать константную память. Существует два способа выделения разделяемой памяти. Простейший заключается в явном задании размеров массивов.
__global__ void inckernel (float *a)
{ __shared__ float buf [256];
buf[threadIdx.x] = a[blockIdx.x*blockDim.x+threadIdx.x];
}
В данном способе компилятор автоматически выделяет необходимое количество разделяемой памяти для каждого блока.
Второй способ предусматривает задание дополнительного объема разделяемой памяти, который необходимо выделить каждому блоку. В этом случае используется описание массива без явно заданного размера.
__global__ void inckernel (float *a)
{ __shared__ float buf [];
buf[threadIdx.x] = a[blockIdx.x*blockDim.x+threadIdx.x];
}
inckernel <<<dim3(n/256),dim3(256), k*sizeof(float)>>> (a);
В данном случае каждому блоку будет дополнительно выделено k*sizeof(float) разделяемой памяти, которая будет доступна через массив buf. Имеется возможность задания нескольких массивов в разделяемой памяти. При этом если их размер не указан, то в момент запуска ядра все они будут расположены в начале выделенной блоку дополнительной разделяемой памяти. Для избегания этого конфликта программист должен сам разделить память между такими массивами.
__global__ void kernel (float *a, int k)
{ __shared__ float buf1[ ];
__shared__ float buf2[ ];
buf1[threadIdx.x] = a[blockIdx.x*blockDim.x+threadIdx.x];
buf2[k+threadIdx.x] = a[blockIdx.x*blockDi.x+threadIdx.x+k];
}
Частным случаем использования разделяемой памяти является хранение постоянно используемых значений.
Оптимизация задачи о N телах.
Рассмотренный вариант задачи упирается в доступ к глобальной памяти. Для каждого тела необходимо произвести N чтений положений других тел. При этом все нити одного блока обращаются к одним и тем же данным, т.е. присутствует много избыточных чтений. Дадим каждому блоку массив в разделяемой памяти. Весь исходный массив тел разделим на группы с длиной, равной размеру блока. Для каждого тайла производится загрузка в разделяемую память. Далее ядро добавляет влияние всех тел данного тайла на тела, просчитываемые нитями блока, после чего переходит к следующему тайлу. Поскольку размер массива в разделяемой памяти равен размеру блока, то каждая нить загружает по одному элементу. Поскольку нити не выполняются физически параллельно, то необходима их синхронизация.
При работе с разделяемой памятью имеются свои способы оптимального доступа. Вся разделяемая память разбита на 16 банков. Каждый из них способен выполнить одно чтение или запись 32-битового слова. Если 16 нитей полуварпа обратятся к 16 подряд идущим 32-битовым словам, то конфликта банков не возникает.
__shared__ float buf[128];
float v = buf[baseIndex+threadIdx.x];
Нити могут обращаться к банкам как угодно, только не в один.
Компилятор Cuda C создает копию переменных в разделяемой памяти в каждом блоке, запускаемой на видеокарте. Все нити, работающие в одном блоке, разделяют эту переменную, но не могут ни увидеть, ни изменить её копию в других блоках. Разделяемая память играет роль внутриблочного программно-управляемого КЭШа. При этом очень важен механизм синхронизации. Без синхронизации возникла бы «гонка», и правильность результатов расчета оказалась бы зависимой от условий оборудования.
Задача: скалярное произведение.
В ядре каждый перемножает пару соответствующих элементов, а после этого переходит к следующей паре. При этом каждая нить сохраняет сумму произведений тех пар, которые обрабатывает. После обработки пары индекс нити увеличивается на величину, равную общему числу нитей. Имеется определение буфера разделяемой памяти с именем cache. В этом буфере будет храниться частичная сумма, вычисленная в данной нити. При выделении глобальной памяти принимались во внимание все нити, исполняющие данное ядро. Размер области вычислялся как произведение числа нитей на общее число блоков. В данном случае компилятор создает копию разделяемых переменных для каждого блока, поэтому требуется выделить ровно столько памяти, чтобы у каждой нити был свой номер. Дойдя до конца массива нить сохранить вычисленную сумму в cache. Далее требуется просуммировать все значения, находящиеся в cache. Процедура, в результате которой из входного массива получается меньший массив, называется редукцией. Главная идея заключается в том, что каждая нить складывает 2 значения и помещает результат обратно в тот же массив cache. Таким образом в результате одной операции число элементов массива уменьшается вдвое. При выходе из цикла в каждом блоке остается одно число. Оно находится в первом элементе массива cache и является суммой парных произведений.