Показаны сообщения с ярлыком cpu. Показать все сообщения
Показаны сообщения с ярлыком cpu. Показать все сообщения

пятница, 29 декабря 2023 г.

2d-коллайдер и его реализация на OpenCL

Улучшаем предыдущий пример. Теперь реализуем этот 2д-коллайдер на OpenCL. Учитывая, что нам надо будет параллельно вычислять и менять текущую позицию и вектор движения одновременно для всех обьектов, то использовать один массив обьектов будет проблематично, т.к. нужно будет использовать различные примитивы синхронизации, что безусловно пагубно скажется на производительности. Поэтому обход/модификация всех обьектов будет производиться следующим образом: один массив обьектов будет только для чтения, а второй только для записи и каждый тред на GPU будет записывать изменения в свою ячейку, не пересекаясь с другими.

  1. __kernel void collideAndUpdate(
  2. __global Ball* b1,
  3. __global Ball* b2,
  4. const int ballCnt,
  5. const int scrW,
  6. const int scrH,
  7. const double frameTimeMs)
  8. {
  9. // Get work-item identifiers.
  10. int i = get_global_id(0);
  11. Ball tmp = b1[i];
  12. for (int j = 0; j < ballCnt; j++)
  13. {
  14. // check collision between one ball to others,
  15. // but don't check collision to itself
  16. if (j != i)
  17. {
  18. Ball tmp2 = b1[j];
  19. tmp = checkCollision(tmp, tmp2);
  20. }
  21. }
  22.  
  23. // check borders
  24. if (tmp.pos.x <= 0 || tmp.pos.x >= scrW)
  25. {
  26. tmp.f.x *= -1;
  27. }
  28. if (tmp.pos.y <= 0 || tmp.pos.y >= scrH)
  29. {
  30. tmp.f.y *= -1;
  31. }
  32.  
  33. // update positions
  34. tmp.pos.x += tmp.f.x * frameTimeMs;
  35. tmp.pos.y += tmp.f.y * frameTimeMs;
  36.  
  37. b2[i] = tmp;
  38. }
Поскольку не получилось подключить C++ заголовочный файл в код ядра, то пришлось по сути переписывать все заново для OpenCL:

  1. Ball checkCollision(Ball b1, const Ball b2)
  2. {
  3. float2 p1 = (float2)(b1.pos.x, b1.pos.y);
  4. float2 p2 = (float2)(b2.pos.x, b2.pos.y);
  5. const float dist = getDistanceBetween(p1, p2);
  6. if (dist < b1.r + b2.r)
  7. {
  8. // direction to other ball
  9. float2 to2 = p2 - p1;
  10. float2 f = (float2)(b1.f.x, b1.f.y);
  11. // calculate dot product
  12. float dotProd = dot(f, to2);
  13. // if dot product is negative then force directed away from B ball
  14. // and we do nothing
  15. if (dotProd > 0)
  16. {
  17. // angle between normal and force (moving) vectors
  18. float angle = getAngleTo(f, to2);
  19. // the angle of incidence is equal to the angle of reflection
  20. f = rotVect(f, angle * 2);
  21. f = f * (-1);
  22. b1.f.x = f.x;
  23. b1.f.y = f.y;
  24. }
  25. }
  26. return b1;
  27. }
И структуру Ball и все векторные операции тоже пришлось переписывать. Потому что OpenCL компилятор и С++ компилятор отличаются очень сильно. Фактически настолько, что и там и там можно использовать только какие-то простые структуры и константы с дефайнами. Вот как выглядят векторные операции:

  1. float getDistanceBetween(float2 p1, float2 p2)
  2. {
  3. float2 p1p2 = p2 - p1;
  4. float dist = native_sqrt(p1p2[0] * p1p2[0] + p1p2[1] * p1p2[1]);
  5. return dist;
  6. }
  7.  
  8. float getLen(float2 p)
  9. {
  10. float len = getDistanceBetween((float2)(0, 0), p);
  11. return len;
  12. }
  13.  
  14. float getCrossProd(float2 p1, float2 p2)
  15. {
  16. float crossProd = p1[0] * p2[1] - p1[1] * p2[0];
  17. return crossProd;
  18. }
  19.  
  20. float getAngleTo(float2 p1, float2 p2)
  21. {
  22. return asin(getCrossProd(p1, p2) / (getLen(p1) * getLen(p2)));
  23. }
  24.  
  25. float2 rotVect(float2 v, float angle)
  26. {
  27. // first of all create rotation matrix
  28. float c = cos(angle);
  29. float s = sin(angle);
  30. float2 mr0 = { c, -s }; // first row
  31. float2 mr1 = { s, c }; // second row
  32. // get rotated vector by multiply matrix to vector
  33. float2 tmp;
  34. tmp[0] = (v[0] * mr0[0] + v[1] * mr0[1]);
  35. tmp[1] = (v[0] * mr1[0] + v[1] * mr1[1]);
  36. return tmp;
  37. }
Я не нашел как работать с матрицами, поэтому в качестве матрицы 2х2 я использовал просто два вектора float2, каждый из которых играет роль строки в матрице (строки 30-31). То есть довольно много заново написанного кода и если кто-то задумал перенести что-то с помощью OpenCL на видеокарту, то пусть имеют ввиду, что такой даже не копипасты, а полной переработки кода с учетом кучи нюансов будет очень много. 
Чтобы увидеть реальную разницу в производительности, понадобилось увеличить количество шариков с 500 до 20000 и уменьшить изх диаметр до 1, чтобы они все поместились. Результат:


Производительность на GPU при 20 тысячах шариков получилась 29.3 кадра в секунду, а на CPU всего лишь 1.6 кадра в секунду. Разница, как говорится, налицо! 

Тестовая платформа: Ryzen 3700X, 16GB RAM, RTX 3060 12GB

Весь код здесь.

вторник, 21 ноября 2023 г.

OpenCL: продолжение. Оптимизация кода и тестирование на бОльших объемах данных

 Продолжение. Начало тут.

В предыдущий раз мы разбивали исходные матрицы на субматрицы меньшего размера, которые перемножали и потом складывали результат. Работает это так:

Почему это работает? Каждый элемент результирующей матрицы это результат перемножения соотв. элементов строки и столбца двух меремножаемых матриц. То есть мы по сути просто разбиваем строки на подстроки и столбцы на подстолбцы, которые соответсвенно содержатся в субматрицах. Вот так это работает:
Ну, в прошлый раз мы реализовали этот алгоритм, а теперь интересно посмотреть, насколько эффективно он работает по сравнению с "наивной" реализацией, когда мы просто напрямую из памяти устройства берем элементы строк и столбцов, не кешируя их в локальную память. Вот код "наивного" метода:

  1. __kernel void multiplyMatrices(__global int* a,
  2. __global int* b,
  3. __global int* c,
  4. const int M,
  5. const int N,
  6. const int K)
  7. {
  8. //Get work - item identifiers.
  9. int colIndex = get_global_id(0);
  10. int rowIndex = get_global_id(1);
  11.  
  12. //Compute element c[rowIndex, colIndex].
  13. int sum = 0;
  14. for (int k = 0; k < K; k++) {
  15. sum += a[rowIndex*K + k] * b[k*N + colIndex];
  16. }
  17. // save result
  18. c[(rowIndex * N) + colIndex] = sum;
  19. }
При размере матриц 1024 на 1024, метод с кешированием работает приблизительно в 1.5 раза быстрее "наивного". Неплохо, но можно лучше! Нашел еще один способ оптимизации. Суть его в том, чтобы каждый тред обрабатывал не один выходной элемент, а несколько - таким образом уменьшится число обращений в память и должна увеличиться производительность. Вот код:

  1. __kernel void multiplyMatrices(__global int* a,
  2. __global int* b,
  3. __global int* c,
  4. const int M,
  5. const int N,
  6. const int K)
  7. {
  8. const int RTS = SUB_SIZE / WPT;
  9. // Get work-item identifiers.
  10. const int row = get_local_id(0); // Local row ID (max: SUB_SIZE)
  11. const int col = get_local_id(1); // Local col ID (max: SUB_SIZE/WPT == RTS)
  12. const int globalRow = SUB_SIZE * get_group_id(0) + row; // Row ID of C (0..M)
  13. const int globalCol = SUB_SIZE * get_group_id(1) + col; // Col ID of C (0..N)
  14.  
  15. // Create submatrices that will cache the matrices A and B in local memory.
  16. __local int aSub[SUB_SIZE][SUB_SIZE];
  17. __local int bSub[SUB_SIZE][SUB_SIZE];
  18.  
  19. // Initialize accumulator
  20. float acc[WPT];
  21. for (int w = 0; w < WPT; w++)
  22. {
  23. acc[w] = 0.0f;
  24. }
  25.  
  26. // Loop over all submatrices.
  27. const int nSub = K / SUB_SIZE;
  28. for(int s = 0; s < nSub; s++)
  29. {
  30. // Load one tile of A and B into local memory
  31. for (int w = 0; w < WPT; w++)
  32. {
  33. const int tiledRow = SUB_SIZE * s + row;
  34. const int tiledCol = SUB_SIZE * s + col;
  35. aSub[col + w * RTS][row] = a[(tiledCol + w * RTS)*M + globalRow];
  36. bSub[col + w * RTS][row] = b[(globalCol + w * RTS)*K + tiledRow];
  37. }
  38.  
  39. // Synchronize all work-items in this work-group.
  40. barrier(CLK_LOCAL_MEM_FENCE);
  41.  
  42. // Perform the computation for a single tile
  43. for (int k = 0; k < SUB_SIZE; k++)
  44. {
  45. for (int w = 0; w < WPT; w++)
  46. {
  47. acc[w] += aSub[k][row] * bSub[col + w * RTS][k];
  48. }
  49. }
  50.  
  51. // Synchronize all work-items in this work-group.
  52. barrier(CLK_LOCAL_MEM_FENCE);
  53. }
  54.  
  55. // Store the final results in C
  56. for (int w = 0; w < WPT; w++)
  57. {
  58. c[(globalCol + w * RTS)*M + globalRow] = acc[w];
  59. }
  60. }
  61.  
В этом коде 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-функции.