Продолжение. Начало тут.
В предыдущий раз мы разбивали исходные матрицы на субматрицы меньшего размера, которые перемножали и потом складывали результат. Работает это так:
Почему это работает? Каждый элемент результирующей матрицы это результат перемножения соотв. элементов строки и столбца двух меремножаемых матриц. То есть мы по сути просто разбиваем строки на подстроки и столбцы на подстолбцы, которые соответсвенно содержатся в субматрицах. Вот так это работает:
Ну, в прошлый раз мы реализовали этот алгоритм, а теперь интересно посмотреть, насколько эффективно он работает по сравнению с "наивной" реализацией, когда мы просто напрямую из памяти устройства берем элементы строк и столбцов, не кешируя их в локальную память. Вот код "наивного" метода:
__kernel void multiplyMatrices(__global int* a,
__global int* b,
__global int* c,
const int M,
const int N,
const int K)
{
//Get work - item identifiers.
int colIndex = get_global_id(0);
int rowIndex = get_global_id(1);
//Compute element c[rowIndex, colIndex].
int sum = 0;
for (int k = 0; k < K; k++) {
sum += a[rowIndex*K + k] * b[k*N + colIndex];
}
// save result
c[(rowIndex * N) + colIndex] = sum;
}
При размере матриц 1024 на 1024, метод с кешированием работает приблизительно в 1.5 раза быстрее "наивного". Неплохо, но можно лучше! Нашел
еще один способ оптимизации. Суть его в том, чтобы каждый тред обрабатывал не один выходной элемент, а несколько - таким образом уменьшится число обращений в память и должна увеличиться производительность. Вот код:
__kernel void multiplyMatrices(__global int* a,
__global int* b,
__global int* c,
const int M,
const int N,
const int K)
{
const int RTS = SUB_SIZE / WPT;
// Get work-item identifiers.
const int row = get_local_id(0); // Local row ID (max: SUB_SIZE)
const int col = get_local_id(1); // Local col ID (max: SUB_SIZE/WPT == RTS)
const int globalRow = SUB_SIZE * get_group_id(0) + row; // Row ID of C (0..M)
const int globalCol = SUB_SIZE * get_group_id(1) + col; // Col ID of C (0..N)
// Create submatrices that will cache the matrices A and B in local memory.
__local int aSub[SUB_SIZE][SUB_SIZE];
__local int bSub[SUB_SIZE][SUB_SIZE];
// Initialize accumulator
float acc[WPT];
for (int w = 0; w < WPT; w++)
{
acc[w] = 0.0f;
}
// Loop over all submatrices.
const int nSub = K / SUB_SIZE;
for(int s = 0; s < nSub; s++)
{
// Load one tile of A and B into local memory
for (int w = 0; w < WPT; w++)
{
const int tiledRow = SUB_SIZE * s + row;
const int tiledCol = SUB_SIZE * s + col;
aSub[col + w * RTS][row] = a[(tiledCol + w * RTS)*M + globalRow];
bSub[col + w * RTS][row] = b[(globalCol + w * RTS)*K + tiledRow];
}
// Synchronize all work-items in this work-group.
barrier(CLK_LOCAL_MEM_FENCE);
// Perform the computation for a single tile
for (int k = 0; k < SUB_SIZE; k++)
{
for (int w = 0; w < WPT; w++)
{
acc[w] += aSub[k][row] * bSub[col + w * RTS][k];
}
}
// Synchronize all work-items in this work-group.
barrier(CLK_LOCAL_MEM_FENCE);
}
// Store the final results in C
for (int w = 0; w < WPT; w++)
{
c[(globalCol + w * RTS)*M + globalRow] = acc[w];
}
}
В этом коде
SUB_SIZE и
WPT - это дефайны, переданные в kernel-код. Первый это размер субматрицы, а второй это Work Per Thread или количество вычисляемых элементов для одного треда. У нас он равен 8. Для того, чтобы это сработало, надо уменьшить количество тредов в рабочей группе. Т.е. нужно сообщить коду ядра, как именно мы хотим разбить работу над массивами. Вот примеры для всех трех методов:
// naive way: each thread calculate one element of out matrix
cl::CommandQueue q(context, device);
q.enqueueNDRangeKernel(kernel, cl::NullRange, cl::NDRange(N, M));
q.enqueueReadBuffer(cBuf, CL_TRUE, 0, M * N * sizeof(int), c);
//...
// submatrix way: group threads into work groups SUB_SIZExSUB_SIZE (8x8)
// and each group caches one tile 8 x 8 submatrix (one thread for one out item)
cl::CommandQueue qe(context, device);
q.enqueueNDRangeKernel(kernel, cl::NullRange, cl::NDRange(N,M), cl::NDRange(SUB_SIZE,SUB_SIZE));
q.enqueueReadBuffer(cBuf, CL_TRUE, 0, M * N * sizeof(int), c);
//...
// submatrix way + WPT: group threads into work groups SUB_SIZExSUB_SIZE (8x8)
// and each group caches one tile 8 x 8 and calculate 8 out matrice elements.
// Also we need to reduce threads amount in group
cl::CommandQueue qe(context, device);
q.enqueueNDRangeKernel(kernel, cl::NullRange, cl::NDRange(N,M/WPT), cl::NDRange(SUB_SIZE,SUB_SIZE/WPT));
q.enqueueReadBuffer(cBuf, CL_TRUE, 0, M * N * sizeof(int), c);
В последнем случае мы уменьшаем количество тредов в рабочей группе и так же уменьшаем количество столбцов, которые обратаывает каждая группа (поскольку теперь она обрабатывает их в 8 раз больше). То есть вот такая как бы разбивка матрицы между рабочими группами.
Теперь самое интересное - результаты. А они, оказывается, очень зависят от размера матрицы. Вот, например, результаты для матриц 1024 на 1024:
Как видно, тут "наивный" способ дает 9.3 секунды, субматричный 6.9 сек, а субматрица+WPT 5.7 секунд. То есть прогресс есть во всех случаях, хотя между submatrix и submatrix+WPT он минимален, хотя и заметен.
Вот результат на матрицах размером 2048 на 2048:
Тут отличия уже гораздо заметнее: наивный способ в 3 раза медленнее, чем submatrix и в 4 раза медленне, чем submatrix+WPT. Это уже очень заметно!
Теперь посмотрим на 4096 на 4096 элементов:
Тут у нас разница еще немного увеличивается, хотя и не сильно отличается от предыдущего замера (2k на 2k элементов). Итого в результате получается, что самый быстрый способ перемножения матриц на GPU превосходит самый быстрый способ на CPU в 3040ms/133ms = 23 раза! Это уже очень значительно.
Весь код можно посмотреть
здесь.
Дополнительные пояснения к коду.
Я все три метода вычислений на GPU (naive, submatrix, submatrix+WPT) поместил в один файл, хотя можно было и в разные, но я хотел посмотреть как работает передача дефайнов в компилятор OpenCL-программы и оказалось, что работает. С помощью дирепктив #if define и #endif я отключил те или иные участки в OpenCL-коде, что может оказаться очень удобно в ряде случаев. А еще использовать дефайны очень удобно, если хочется передать какой-то дополнительный параметр, но не хочется менять сигнатуру kernel-функции.