Нижегородский государственный университет им. Н.И. Лобачевского Факультет Вычислительной математики и кибернетики Исполнение потоков. Иерархия памяти CUDA Бастраков С.И. Содержание Исполнение потоков Иерархия памяти CUDA Пример: параллельная редукция Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 2 Исполнение потоков Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 3 Основные понятия Большое количество потоков (thread) параллельно выполняют одну и ту же функцию – ядро (kernel). – На Fermi одновременно может исполняться до 4 разных ядер. На более старых архитектурах одновременно может исполняться только 1 ядро. Потоки группируются в блоки (thread blocks). Каждый блок исполняется на одном мультипроцессоре, его потоки – на CUDA-ядрах данного мультипроцессора. Блоки объединяются в решетку/сетку блоков (grid). Ядро выполняется на решетке из блоков. Размер блока и размер решетки блоков задается при вызове ядра. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 4 Идентификаторы Каждый поток и блок потоков имеют идентификаторы – Каждый поток и блок могут (и должны) определить, с какими данными они должны работать. Block ID (1D, 2D или 3D). – Третья компонента не использовалась до CUDA 4.0. Thread ID (1D, 2D или 3D). Многомерная индексация может упрощать декомпозицию многомерных данных. Пример: ядро выполняет умножение матриц, каждый поток вычисляет один элемент результирующей матрицы. Удобно использовать двумерные индексы, например, xкомпоненту для номеров строк и y-компоненту для столбцов. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 5 Идентификаторы Источник: NVIDIA CUDA C Programming Guide v. 4.0 Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 6 Взаимодействие потоков Потоки внутри одного блока выполняются на одном мультипроцессоре, они способны взаимодействовать между собой посредством: – разделяемой памяти ; – точек синхронизации. Два потока из различных блоков могут взаимодействовать лишь через глобальную память. Атомарные функции для разделяемой и глобальной памяти. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 7 Выполнение блоков Автоматическое распределение блоков на мультипроцессоры. Каждый блок целиком выполняется одним мультипроцессором. При наличии достаточного количества ресурсов, несколько блоков могут «одновременно» исполняться на одном мультипроцессоре. Наличие большого количества блоков открывает возможности для автоматической масштабируемости с ростом числа мультипроцессоров. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 8 Автоматическая масштабируемость Источник: NVIDIA CUDA C Programming Guide v. 4.0 Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 9 SIMT Все потоки, выполняющиеся на одном мультипроцессоре, группируются в варпы (warp), в варп попадают потоки с последовательными идентификаторами. – Размер варпа на текущих архитектурах равен 32. Выполнение производится варпами, аппаратный планировшик на мультипроцессоре – На Fermi 2 планировщика на мультипроцессор. CUDA-ядра данного мультипроцессора параллельно выполняют одну и ту же инструкцию для всех потоков варпа (SIMT, Single Instruction Multiple Thread). – Потоки одного варпа всегда синхронизованы. Программирование в скалярных терминах (скалярный код для каждого потока, возможность ветвлений). Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 10 Пример выполнения варпов Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 11 Выполнение ветвлений Все потоки варпа исполняют одну инструкцию, поэтому одновременное исполнение ими разных веток условных выражений невозможно. При возникновении таких ветвлений последовательно исполняются все их ветки. В зависимости от выполнения условия входа в очередную ветку потоки делятся на активные и неактивные. С точки зрения планировщика все потоки работают как SIMD, операции доступа к памяти для неактивных потоков не выполняются. Таким образом, логически выполнение ветвлений происходит корректно, время выполнения примерно равно суммарному времени выполнения всех ветвей. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 12 Оптимизация загрузки устройства Число потоков на мультипроцессор обычно гораздо больше числа CUDA-ядер. Большое число потоков обеспечивает скрытие латентности доступа к памяти, особенно глобальной. Степень загрузки устройства (occupancy) – отношение количества варпов, работающих на мультипроцессоре, к максимально возможному количеству варпов. Ограничивающим для числа потоков фактором является количество разделяемой памяти и регистров. При наличии достаточного количества ресурсов несколько блоков могут одновременно исполняться на одном мультипроцессоре. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 13 Иерархия памяти CUDA Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 14 Типы памяти и скорость доступа Тип памяти Доступ Уровень выделения Скорость работы Регистры R/W Per-thread Высокая (on-chip) Локальная R/W Per-thread Низкая (DRAM) Разделяемая R/W Per-block Высокая (on-chip) Глобальная* R/W Per-grid Низкая (DRAM) Константная R/O Per-grid Высокая при попадании в кэш Текстурная R/O Per-grid Высокая при попадании в кэш *+ L1/L2 кэш на Fermi Источник: А.В. Боресков, А.А. Харламов «Архитектура и программирование массивно-параллельных вычислительных систем» Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 15 Использование локальной памяти Локальная память логически эксклюзивна для потока. Физически она расположена в области памяти устройства и имеет ту же латентность, что и глобальная память. Обычно служит для хранения локальных переменных потоков, не убирающихся в регистры. Часто компилятор помещает локальные статические массивы в локальную память. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 16 Использование константной памяти Константная память служит для хранения данных, не меняющихся во время исполнения ядер. Значения можно инициализировать при определении либо копировать со стороны хоста с использованием функций API. Расположена в памяти устройства (как и глобальная память), кэшируется на всех устройствах. Малый размер (десятки килобайт). Пример использования: хранение матрицы коэффициентов косинус-преобразования для блоков малого размера в алгоритмах кодирования изображений, хранение матриц фильтров. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 17 Использование текстурной памяти Текстуры широко применяются в компьютерной графике, многие операции над ними поддерживаются аппаратно (например, интерполяция). Текстурная память расположена в области памяти устройства и кэшируется на всех устройствах. Оптимизирована для двумерной локальности доступа. С появлением архитектуры Fermi и кэша для глобальной памяти стала использоваться гораздо реже. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 18 Использование глобальной памяти Использование переменных с квалификатором __device__ аналог глобальных статических переменных, возможность доступа к ним со стороны хоста. Выделение/освобождение памяти со стороны хоста с использованием cudaMalloc/cudaFree. В последней версии CUDA добавлена возможность динамического выделения памяти из ядер. – При этом размер «кучи» определяется со стороны хоста. L1/L2 кэщ для глобальной памяти появился на архитектуре Fermi. Латентность 400-600 тактов (без учета кэша). Оптимизация работы с глобальной памятью обычно имеет очень большое значение. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 19 Эффективная работа с глобальной памятью Доступ в глобальную память осуществляется полуварпами. Каждый поток обращается к 32-, 64- или 128-битным словам. При выполнении определенных условий происходит объединение запросов (коалесцирование, coalescing) к памяти всех потоков полуварпа в одну операцию доступа к непрерывному блоку памяти и выполнение их одной инструкцией. В противном случае доступ полуварпа к памяти разбивается на несколько последовательных доступов к непрерывным блокам памяти. Условия для объединения запросов зависят от вычислительных возможностей, постепенно они становятся все более мягкими. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 20 Эффективная работа с глобальной памятью Условия, необходимые для объединения запросов: – Доступ в пределах одного последовательного участка памяти определенного размера. – Выровненность адресов, с которыми работает каждый поток, по размеру типа. Выровненность автоматически обеспечивается для встроенных векторных типов, для обеспечения выровненности структур используется __align__ (newsize) при их объявлении. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 21 Пример доступа к глобальной памяти Источник: NVIDIA CUDA C Programming Guide v. 4.0 Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 22 Пример доступа к глобальной памяти Источник: NVIDIA CUDA C Programming Guide v. 4.0 Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 23 Пример доступа к глобальной памяти Источник: NVIDIA CUDA C Programming Guide v. 4.0 Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 24 Рекомендации по эффективной работе с глобальной памятью За счет объединения запросов можно добиться значительного уменьшения времени на доступ к глобальной памяти. Объединение запросов заведомо невозможно, когда адреса, по которым обращаются потоки одного полуварпа, расположены далеко друг от друга. Для обеспечения объединения запросов обычно стоит предпочитать регулярные структуры данных нерегулярным. В случае независимой обработки вместо массива структур обычно лучше использовать массивы отдельных компонент. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 25 Пример /* Одна итерация метода Якоби для системы размера n на n: x0 – текущее приближение, x1 – следующее, матрица a хранится по строкам, f – правая часть. Каждый поток вычисляет свой элемент вектора x1, общее число потоков равно n. */ __global__ void kernel (float * a, float * f, float * x0, float * x1, int n) { int idx = blockIdx.x * blockDim.x + threadIdx.x; int ia = n * idx; float sum = 0.0f; for (int i = 0; i < n; i++) sum += a[ia + i] * x0[i]; /* плохой вариант доступа к a, лучше хранить ее по столбцам */ float alpha = 1.0f / a[ia + idx]; x1[idx] = x0[idx] + alpha * (f[idx] - sum); } Использованы материалы: А.В. Боресков, А.А. Харламов «Архитектура и программирование массивно-параллельных вычислительных систем» Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 26 Работа с разделяемой памятью Латентность 4 такта. Малый размер (от 16 или 48kB). Типичная схема использования разделяемой памяти для уменьшения времени доступа к глобальной памяти: – загрузка интенсивно используемых данных из глобальной памяти; – синхронизация (при необходимости); – вычисления с использованием загруженных данных; – синхронизация (при необходимости); – запись результатов в глобальную память. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 27 Пример Всем потокам блока необходимо работать с определенным участком массива в глобальной памяти. Каждый поток загружает один элемент в соответствующий элемент массива в разделяемой памяти. Считается, что число потоков равно (или меньше чем) NUM_THREADS. __global__ void kernel(int * a) { int globalIdx = blockIdx.x * blockDim.x + threadIdx.x; __shared__ int shared_a[NUM_THREADS]; shared_a[threadIdx.x] = a[globalIdx]; __syncthreads(); // все потоки блока могут использовать shared_a } Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 28 Динамически выделяемая разделяемая память Данные в разделяемой памяти, размер которых определяется при запуске ядра (аналог динамических массивов). Может использоваться внутри ядер и __device__-функций. Синтаксис (только для динамически выделяемой разделяемой памяти): – Объявление переменной с квалификаторами extern __shared__ перед телом функции. – Получение адресов начал массивов при помощи сдвигов. Начало каждого массива должно быть выровнено по размеру типа. – Указание общего размера используемой таким образом разделяемой памяти в качестве третьего аргумента при запуске ядра (по умолчанию 0). Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 29 Пример Пусть мы хотим выделить массив array0 типа short длины 2n, массив array1 типа float длины n и массив array2 типа int длины 3n. extern __shared__ float array[]; __global__ void kernel(int n) { short* array0 = (short*)array; float* array1 = (float*)&array0[2 * n]; int* array2 = (int*)&array1[n]; } … int shared_mem_size = sizeof(short) * 2 * n + sizeof(float) * n + sizeof(int) * 3 * n; kernel<<<num_blocks, num_threads, shared_mem_size>>>(n); Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 30 Эффективная работа с разделяемой памятью Разделяемая память разбита на банки (страницы) таким образом, что последовательные 32-битные слова попадают в последовательные банки. Каждый банк работает независимо от других, возможен параллельный доступ к различным банкам. Доступ нескольких потоков к одному банку сериализуется (происходит конфликт банков), исключение – чтение всеми потоками данных из одного и того же банка. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 31 Эффективная работа с разделяемой памятью Пример: __shared__ float x[32]; float data = x[BaseIndex + s * tid]; Пусть tid – thread ID, s – шаг доступа к элементам массива. Потоки с ID tid и tid+n вызовут конфликт банков, если s*n кратно числу банков m. Для того, чтобы избежать конфликтов банков, необходимо НОД(m, s) = 1. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 32 Доступ к разделяемой памяти без конфликтов банков Источник: NVIDIA CUDA C Programming Guide v. 4.0 Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 33 Доступ к разделяемой памяти с конфликтами банков Источник: NVIDIA CUDA C Programming Guide v. 4.0 Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 34 Пример: параллельная редукция Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 35 Постановка задачи Дано: – Массив a0 , a1 ,..., an1 – Ассоциативная операция «+» (например, +, *, min, max) Необходимо найти: A a0 a1 ... an1 Лимитирующий фактор – доступ к памяти. Источник материала данного раздела: пример reduction в GPU Computing SDK и соответствующая статья; А.В. Боресков, А.А. Харламов «Иерархия памяти CUDA. Shared-память и ее эффективное использование. Параллельная редукция». Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 36 Иерархическое суммирование Распределяем исходный массив между блоками. Каждый блок производит иерархическое суммирование в разделяемой памяти. 5 3 7 8 -2 2 5 0 4 2 13 -5 -6 -1 2 1 -4 1 -3 4 -2 -6 9 -6 14 5 3 -3 6 0 14 Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 37 Вариант 1 Thread a 0 1 2 3 4 5 6 7 8 2 10 11 12 13 14 15 5 3 7 -2 2 0 4 -5 -6 2 1 -3 4 5 -6 3 8 3 5 -2 2 0 -1 -5 -4 2 -2 -3 9 5 -3 3 13 3 5 -2 1 0 -1 -5 -6 2 -2 -3 6 5 -3 3 14 3 5 -2 1 0 -1 -5 0 2 -2 -3 6 5 -3 3 14 3 5 -2 1 0 -1 -5 0 2 -2 -3 6 5 -3 3 s=1 s=2 s=4 s=8 Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 38 Вариант 1 __global__ void reduce1 ( int * inData, int * outData ) { __shared__ int data [BLOCK_SIZE]; int tid = threadIdx.x; int i = blockIdx.x * blockDim.x + threadIdx.x; data [tid] = inData [i]; // load into shared memory __syncthreads (); for ( int s = 1; s < blockDim.x; s *= 2 ) { if ( tid % (2*s) == 0 ) // heavy branching !!! data [tid] += data [tid + s]; __syncthreads (); } if ( tid == 0 ) // write result of block reduction outData[blockIdx.x] = data [0]; } Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 39 Вариант 2 __global__ void reduce2 ( int * inData, int * outData ) { __shared__ int data [BLOCK_SIZE]; int tid = threadIdx.x; int i = blockIdx.x * blockDim.x + threadIdx.x; data [tid] = inData [i]; // load into shared memory __syncthreads (); for ( int s = 1; s < blockDim.x; s <<= 1 ) { int index = 2 * s * tid; if ( index < blockDim.x ) data [index] += data [index + s]; __syncthreads (); } if ( tid == 0 ) // write result of block reduction outData [blockIdx.x] = data [0]; } Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 40 Вариант 2 Отличие второго варианта от первого лишь в том, какие потоки выполняют (ту же самую) работу. Во втором варианте почти полностью избавились от ветвления. Однако на каждой следующей итерации циклов число конфликтов страниц памяти удваивается: – s = 1: конфликт страниц 2го порядка (index = 1 при tid = 0 и index = 17 при tid = 8); – s = 2: конфликт страниц 4го порядка (index = 2 при tid = 0, index = 18 при tid = 4, index = 34 при tid = 8, index = 50 при tid = 12); – … Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 41 Вариант 3 Изменим порядок суммирования: будем начинать его не с ближайших, а с удаленных на blockDim.x/2 элементов и будем уменьшать это расстояние вдвое на каждом шаге. Thread a 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 5 3 7 -2 2 0 4 -5 -6 2 1 -3 4 5 -6 3 -1 5 8 -5 6 5 -2 -2 -6 2 1 -3 4 5 -6 3 5 10 6 -7 6 5 -2 -2 -6 2 1 -3 4 5 -6 3 11 3 6 -7 6 5 -2 -2 -6 2 1 -3 4 5 -6 3 14 3 6 7 6 5 -2 -2 -6 2 1 -3 4 5 -6 3 s=1 s=2 s=4 s=8 Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 42 Вариант 3 __global__ void reduce3 ( int * inData, int * outData ) { __shared__ int data [BLOCK_SIZE]; int tid = threadIdx.x; int i = blockIdx.x * blockDim.x + threadIdx.x; data [tid] = inData [i]; __syncthreads (); for ( int s = blockDim.x / 2; s > 0; s >>= 1 ) { if ( tid < s ) data [tid] += data [tid + s]; __syncthreads (); } if ( tid == 0 ) outData [blockIdx.x] = data [0]; } Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 43 Вариант 4 В варианте 3 избавились от конфликтов банков. На первой итерации половина потоков простаивает, для избежания этого сделаем первое суммирование при загрузке. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 44 Вариант 4 __global__ void reduce4 ( int * inData, int * outData ) { __shared__ int data [BLOCK_SIZE]; int tid = threadIdx.x; int i = 2 * blockIdx.x * blockDim.x + threadIdx.x; data [tid] = inData [i] + inData [i+blockDim.x]; // sum __syncthreads (); for ( int s = blockDim.x / 2; s > 0; s >>= 1 ) { if ( tid < s ) data [tid] += data [tid + s]; __syncthreads (); } if ( tid == 0 ) outData [blockIdx.x] = data [0]; } Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 45 Вариант 5 При s <= 32 в блоке работает только один варп. Можно развернуть цикл, избавившись от синхронизации и проверки условия. for ( int s = blockDim.x / 2; s > 32; s >>= 1 ) { if ( tid < s ) data [tid] += data [tid + s]; __syncthreads (); } if ( tid < 32 ) { // unroll last iterations data [tid] += data [tid + 32]; data [tid] += data [tid + 16]; data [tid] += data [tid + 8]; data [tid] += data [tid + 4]; data [tid] += data [tid + 2]; data [tid] += data [tid + 1]; } Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 46 Результаты Вариант алгоритма 1 2 3 4 5 Время выполнения (миллисекунды) 19.09 11.91 10.62 9.10 8.67 Возможна дальнейшая оптимизация: полное разворачивание на этапе компиляции при помощи параметров шаблонов. В примере в SDK данный вариант реализован. Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 47 Материалы NVIDIA CUDA C Programming Guide v. 4.0: http://developer.download.nvidia.com/compute/cuda/4_0/tool kit/docs/CUDA_C_Programming_Guide.pdf Материалы курса по CUDA в МГУ: https://sites.google.com/site/cudacsmsusu/file-cabinet А.В. Боресков, А.А. Харламов «Основы работы с технологией CUDA»: https://sites.google.com/site/cudacsmsusu/file-cabinet Д. Сандерс, Э. Кэндрот «Технология CUDA в примерах: введение в программирование графических процессоров» (пер. с англ.). Н. Новгород, 2011 г. Исполнение потоков. Иерархия памяти CUDA 48