Продолжение. Начало тут.
В предыдущий раз мы разбивали исходные матрицы на субматрицы меньшего размера, которые перемножали и потом складывали результат. Работает это так:
Почему это работает? Каждый элемент результирующей матрицы это результат перемножения соотв. элементов строки и столбца двух меремножаемых матриц. То есть мы по сути просто разбиваем строки на подстроки и столбцы на подстолбцы, которые соответсвенно содержатся в субматрицах. Вот так это работает:
Ну, в прошлый раз мы реализовали этот алгоритм, а теперь интересно посмотреть, насколько эффективно он работает по сравнению с "наивной" реализацией, когда мы просто напрямую из памяти устройства берем элементы строк и столбцов, не кешируя их в локальную память. Вот код "наивного" метода:
- __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;
- }
- __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];
- }
- }
// 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 элементов:
Весь код можно посмотреть здесь.
Дополнительные пояснения к коду.
Я все три метода вычислений на GPU (naive, submatrix, submatrix+WPT) поместил в один файл, хотя можно было и в разные, но я хотел посмотреть как работает передача дефайнов в компилятор OpenCL-программы и оказалось, что работает. С помощью дирепктив #if define и #endif я отключил те или иные участки в OpenCL-коде, что может оказаться очень удобно в ряде случаев. А еще использовать дефайны очень удобно, если хочется передать какой-то дополнительный параметр, но не хочется менять сигнатуру kernel-функции.
Комментариев нет:
Отправить комментарий