Занятие 7. Программирование на CUDA C. Часть 3.
Увеличение значение у элементов в матрице на единицу
Следующая программа будет увеличивать значение элемента в матрице на единицу. Объяснение этой программы и последующих будет построено несколько иным образом, так как останавливаться на каждой строчке и объяснять один и тот же механизм будет достаточно скучным чтением.
Поэтому ниже будет приведен листинг всего кода с комментариями по новым вещам, что ранее в программах не было, а после каждый момент будет подробно разобран. Итак, листинг:
void incKernel ( float * data ) { int idx = blockIdx.x * blockDim.x + threadIdx.x; data [idx] = data [idx] + 1.0f; } int main ( int argc, char * argv [] ) { int n = 16 * 1024 * 1024; int numBytes = n * sizeof ( float ); // выделение памяти на хосте float * a = new float [n]; for ( int i = 0; i < n; i++ ) a [i] = 0.0f; // выделение памяти на девайсе float * dev = NULL; cudaMalloc ( (void**)&dev, numBytes ); // Установка конфигурации запуска ядра dim3 threads = dim3(512, 1); dim3 blocks = dim3(n / threads.x, 1); // создание обработчиков событий cuda cudaEvent_t start, stop; float gpuTime = 0.0f; cudaEventCreate ( &start ); cudaEventCreate ( &stop ); // асинхронно выдаем работу на GPU (все в поток 0) cudaEventRecord ( start, 0 ); cudaMemcpy ( dev, a, numBytes, cudaMemcpyHostToDevice ); incKernel<<<blocks, threads>>>(dev); cudaMemcpy ( a, dev, numBytes, cudaMemcpyDeviceToHost ); cudaEventRecord ( stop, 0 ); cudaEventSynchronize ( stop ); cudaEventElapsedTime ( &gpuTime, start, stop ); // Печатаем время работы на CPU и GPU printf("time spent executing by the GPU: %.2f millseconds\n", gpuTime ); // проверка аутпута на корректность printf("--------------------------------------------------------------\n"); for ( int i = 0; i < n; i++ ) if ( a [i] != 1.0f ) { printf ( "Error in pos %d, %f\n", i, a [i] ); break; } // освобождение ресурсов cudaEventDestroy ( start ); cudaEventDestroy ( stop ); cudaFree( dev ); delete a; return 0; }
В примере продемонстрирован обработчик событий, который не был ранее описан. Итак, обработчик событий CUDA cudaEvent_t start, stop задает начало и конец события. С помощью переменной float gpuTime = 0.0f будет производиться расчет времени выполнения программы в миллисекундах. Запись события производится с помощью специального объявления cudaEventRecord ( start/stop, 0 ). Вычисление времени производится с помощью cudaEventElapsedTime. Это время, которое потребовалось для вычисления только на стороне GPU. В последующих примерах работа с записью времени подробно описываться не будет, за исключением того, что не было описано здесь.
threadIdx и blockIdx определить какой именно элемент соответствует данному треду и увеличить именно его.
Так как и блоки и grid одномерны, то номер треда будет определятся как произведение номера блока на количество тредов в блоке, плюс номер треда внутри блока, т.е. blockIdx.x * blockDim.x + threadIdx.x.
cudaMalloc выделяется память под копию массива с данными в глобальной памяти (DRAM GPU). Далее данные копируются функцией cudaMemcpy из памяти CPU к глобальную память GPU.
По завершению копирования данных в глобальную память запускается ядро для обработки данных и после его вызова копируются результаты вычислений обратно из глобальной памяти GPU в память CPU.
Результатом выполнения будет замер затраченного на копирование и вычисления времени программы, а также проверка на корректность получаемого результата:
Перемножение двух матриц
Следующий пример будет чуть сложнее, но не менее интереснее — перемножение двух квадратных матриц размерности N*N.
Допустим у нас есть две квадратные матрицы A и B размера N*N (будем считать, что N кратно 16). Простейший вариант использует по одному треду на каждый элемент получающейся матрицы C, при этом тред извлекает все необходимые элементы из глобальной памяти и производит требуемые вычисления.
Элемент ci,j произведения двух матриц A и B вычисляется следующим фрагментом псевдокода:
Для каждого k-го элемента от 0 до N-1 считать:
c [i][j] += a [i][k] * b [k][j]
Таким образом для вычисления одного элемента произведения матриц нужно выполнить 2*N арифметических операций и 2*N чтений из глобальной памяти. Очевидно, что в основным лимитирующим фактором является скорость доступа к глобальной памяти, которая весьма низка. Опустим этот момент (его мы разберем в следующем примере), так как пример демонстрирует перемножение двух квадратных матриц, а не оптимизирует скорость доступа. Итак, листинг:
#define BLOCK_SIZE 16 // submatrix size #define N 1024 // matrix size is N*N __global__ void matMult ( float * a, float * b, int n, float * c ) { int bx = blockIdx.x; // block index int by = blockIdx.y; int tx = threadIdx.x; // thread index int ty = threadIdx.y; float sum = 0.0f; // computed subelement int ia = n * BLOCK_SIZE * by + n * ty; // a [i][0] int ib = BLOCK_SIZE * bx + tx; // Multiply the two matrices together; for ( int k = 0; k < n; k++ ) sum += a [ia + k] * b [ib + k*n]; // Write the block sub-matrix to global memory; // each thread writes one element int ic = n * BLOCK_SIZE * by + BLOCK_SIZE * bx; c [ic + n * ty + tx] = sum; } int main ( int argc, char * argv [] ) { int numBytes = N * N * sizeof ( float ); // выделение памяти на хосте float * a = new float [N*N]; float * b = new float [N*N]; float * c = new float [N*N]; for ( int i = 0; i < N; i++ ) for ( int j = 0; j < N; j++ ) { int k = N*i + j; a [k] = 0.0f; b [k] = 1.0f; } // выделение памяти на девайсе float * adev = NULL; float * bdev = NULL; float * cdev = NULL; cudaMalloc ( (void**)&adev, numBytes ); cudaMalloc ( (void**)&bdev, numBytes ); cudaMalloc ( (void**)&cdev, numBytes ); // Установка конфигурации запуска ядра dim3 threads ( BLOCK_SIZE, BLOCK_SIZE ); dim3 blocks ( N / threads.x, N / threads.y); // Создание обработчика событий CUDA cudaEvent_t start, stop; float gpuTime = 0.0f; cudaEventCreate ( &start ); cudaEventCreate ( &stop ); // асинхронно выдаваем работу на GPU (все в поток 0) cudaEventRecord ( start, 0 ); cudaMemcpy ( adev, a, numBytes, cudaMemcpyHostToDevice ); cudaMemcpy ( bdev, b, numBytes, cudaMemcpyHostToDevice ); matMult<<<blocks, threads>>> ( adev, bdev, N, cdev ); cudaMemcpy ( c, cdev, numBytes, cudaMemcpyDeviceToHost ); cudaEventRecord ( stop, 0 ); cudaEventSynchronize ( stop ); cudaEventElapsedTime ( &gpuTime, start, stop ); // Печатаем время работы на GPU и CPU printf("time spent executing by the GPU: %.2f millseconds\n", gpuTime ); // Освобождение ресурсов cudaEventDestroy ( start ); cudaEventDestroy ( stop ); cudaFree ( adev ); cudaFree ( bdev ); cudaFree ( cdev ); delete a; delete b; delete c; return 0; }
Естественно, саму матрицу вы можете задать произвольно, в данном примере была указана лишь размерность без каких-либо значений элементов. Данный пример демонстрирует производительность перемножения двух матриц размерностей N*N:
Перемножение двух матриц с использованием shared-памяти
Поскольку используется разбиение бэндов на квадратные подматрицы, то псевдокод расчета элемент ci,j изменится:
Для каждого шага от 0 до N/16 считать:
По каждому k-му элементу от 0..16 считать:
c [i][j] += a [i][k+step*16] * b [k+step*16][j]
Следует обратить внимание, что для каждого значения step значения из матриц A и B берутся из двух подматриц размером 16*16. Фактически полосы с рис. 4 просто поделены на квадратные подматрицы и каждому значению step соответствует по одной такой подматрице A и одной подматрице B.
На каждом шаге будем загружать в shared-память по одной 16*16 подматрице A и одной 16*16 подматрице B. Далее будем вычислять соответствующую им сумму для элементов произведения, потом загружаем следующие 16*16-подматрицы и т.д.
При этом на каждом шаге один тред загружает ровно по одному элементу из каждой из матриц A и B и вычисляет соответствующую им сумму членов. По окончании всех вычислений производится запись элемента в итоговую матрицу.
Следует обратить внимание, что после загрузки элементов из A и B нужно выполнить синхронизацию тредов при помощи вызова __synchronize для того, чтобы к моменту начала расчетов все нужные элементы (загружаемые остальными тредами блока) были бы уже загружены. Точно также по окончании обработки загруженных подматриц и перед загрузкой следующих также нужна синхронизация.
Ниже приводится соответствующий исходный код:
#define BLOCK_SIZE 16 // submatrix size #define N 1024 // matrix size is N*N __global__ void matMult ( float * a, float * b, int n, float * c ) { int bx = blockIdx.x; // block index int by = blockIdx.y; int tx = threadIdx.x; // thread index int ty = threadIdx.y; // Index of the first sub-matrix of A processed by the block int aBegin = n * BLOCK_SIZE * by; int aEnd = aBegin + n - 1; // Step size used to iterate through the sub-matrices of A int aStep = BLOCK_SIZE; // Index of the first sub-matrix of B processed by the block int bBegin = BLOCK_SIZE * bx; // Step size used to iterate through the sub-matrices of B int bStep = BLOCK_SIZE * n; float sum = 0.0f; // computed subelement for ( int ia = aBegin, ib = bBegin; ia <= aEnd; ia += aStep, ib += bStep ) { // Shared memory for the sub-matrix of A __shared__ float as [BLOCK_SIZE][BLOCK_SIZE]; // Shared memory for the sub-matrix of B __shared__ float bs [BLOCK_SIZE][BLOCK_SIZE]; // Load the matrices from global memory to shared memory; as [ty][tx] = a [ia + n * ty + tx]; bs [ty][tx] = b [ib + n * ty + tx]; __syncthreads(); // Synchronize to make sure the matrices are loaded // Multiply the two matrices together; for ( int k = 0; k < BLOCK_SIZE; k++ ) sum += as [ty][k] * bs [k][tx]; // Synchronize to make sure that the preceding // computation is done before loading two new // sub-matrices of A and B in the next iteration __syncthreads(); } // Write the block sub-matrix to global memory; // each thread writes one element int ic = n * BLOCK_SIZE * by + BLOCK_SIZE * bx; c [ic + n * ty + tx] = sum; } int main ( int argc, char * argv [] ) { int numBytes = N * N * sizeof ( float ); // allocate host memory float * a = new float [N*N]; float * b = new float [N*N]; float * c = new float [N*N]; for ( int i = 0; i < N; i++ ) for ( int j = 0; j < N; j++ ) { a [i] = 0.0f; b [i] = 1.0f; } // allocate device memory float * adev = NULL; float * bdev = NULL; float * cdev = NULL; cudaMalloc ( (void**)&adev, numBytes ); cudaMalloc ( (void**)&bdev, numBytes ); cudaMalloc ( (void**)&cdev, numBytes ); // set kernel launch configuration dim3 threads ( BLOCK_SIZE, BLOCK_SIZE ); dim3 blocks ( N / threads.x, N / threads.y); // create cuda event handles cudaEvent_t start, stop; float gpuTime = 0.0f; cudaEventCreate ( &start ); cudaEventCreate ( &stop ); // asynchronously issue work to the GPU (all to stream 0) cudaEventRecord ( start, 0 ); cudaMemcpy ( adev, a, numBytes, cudaMemcpyHostToDevice ); cudaMemcpy ( bdev, b, numBytes, cudaMemcpyHostToDevice ); matMult<<<blocks, threads>>> ( adev, bdev, N, cdev ); cudaMemcpy ( c, cdev, numBytes, cudaMemcpyDeviceToHost ); cudaEventRecord ( stop, 0 ); cudaEventSynchronize ( stop ); cudaEventElapsedTime ( &gpuTime, start, stop ); // print the cpu and gpu times printf("time spent executing by the GPU: %.2f millseconds\n", gpuTime ); // release resources cudaEventDestroy ( start ); cudaEventDestroy ( stop ); cudaFree ( adev ); cudaFree ( bdev ); cudaFree ( cdev ); delete a; delete b; delete c; return 0; }
Теперь для вычисления одного элемента произведения матриц нам нужно всего 2*N/16 чтений из глобальной памяти. И по результатам сразу видно за счет использования shared-памяти нам удалось поднять быстродействие более чем на порядок – 3.66 милисекунд: