вторник, 5 декабря 2023 г.

OpenCL. Обновление OpenCL/CUDA SDK и неожиданные результаты повторных тестов.

Оказывается, все это время я работал со старой версией SDK, выпущенной аж в 2017 году (OpenCL 2.1 или 2.2). Конечно же, я немедленно обновился до свежайшей версии 2023 года (OpenCL 3.0) и перезапустил все тесты и был очень сильно удивлен результатами. При тестировании на прежней версии SDK результаты для матрицы 4094*4096 были следующими:

naive:         849 ms
submatrix:     193 ms
submatrix+WPT: 132 ms
Теперь же, при новом SDK результаты получапются вот такие:

naive:         61 ms
submatrix:     60 ms
submatrix+WPT: 64 ms
Я подумал, что может я смогу увидеть разницу на больших обьемах данных и проверил на матрице 8192*8192 и получил вот такие результаты:

naive:         241 ms
submatrix:     237 ms
submatrix+WPT: 236 ms
То есть практически одианковые. Получается, с одной стороны, новая версия SDK увеличила скорость работы так, что перемножение работает в два раза быстрее, чем в самом быстром способе в предыдущем  SDK, а с другой стороны - все остальные способы работают так же быстро, как самый быстрый! То есть теперь нет никакого смысла что-то оптимизировать, а все мои предыдущие изыскания носят скорее академический интерес в данном случае. Я не знаю, что именно они там поменяли, но результаты просто фантастические. 



понедельник, 4 декабря 2023 г.

OpenCL: продолжение работы с изображениями. Оптимизация kernel-кода и неожиданные результаты тестов. Использование printf при отладке kernel-кода

 Продолжение. Начало здесь.

Итак, теперь мы попробуем оптимизировать код обработки изображения при помощи локальной памяти и рабочих групп. Будем, как и в случае работы с перемножением матриц, кешировать значения в локальный буфер. Код выглядит так:

  1. // This kernel function convolves an image input_image[imgWidth, imgHeight]
  2. // with a mask of size maskSize by caching submatrices from the input image
  3. // in the device local memory.
  4. __kernel void filterImageCached(
  5. __global unsigned char* inImg,
  6. __global unsigned char* outImg,
  7. const int bytesPerPix,
  8. const unsigned int maskSize,
  9. __constant float* mask)
  10. {
  11. // Get work-item identifiers.
  12. int x = get_global_id(0);
  13. int y = get_global_id(1);
  14. int lx = get_local_id(0);
  15. int ly = get_local_id(1);
  16. int imgW = get_global_size(0);
  17. int imgH = get_global_size(1);
  18. int offset = ((y * imgW) + x) * bytesPerPix;
  19.  
  20. // Declare submatrix used to cache the input image on local memory.
  21. __local unsigned char sub[SUB_SIZE][SUB_SIZE][4];
  22.  
  23. sub[ly][lx][0] = inImg[offset];
  24. sub[ly][lx][1] = inImg[offset + 1];
  25. sub[ly][lx][2] = inImg[offset + 2];
  26. if (bytesPerPix == 4) // if we have alfa-channel
  27. sub[ly][lx][3] = inImg[offset + 3];
  28.  
  29. // Synchronize all work-items in this work-group.
  30. barrier(CLK_LOCAL_MEM_FENCE);
  31.  
  32. // Check if the mask cannot be applied to the current pixel
  33. if (x < maskSize / 2
  34. || y < maskSize / 2
  35. || x >= imgW - maskSize / 2
  36. || y >= imgH - maskSize / 2)
  37. {
  38. outImg[offset] = 0;
  39. outImg[offset + 1] = 0;
  40. outImg[offset + 2] = 0;
  41. if (bytesPerPix == 4) // if we have alfa-channel
  42. outImg[offset + 3] = inImg[offset + 3];
  43. return;
  44. }
  45.  
  46. // Apply mask based on the neighborhood of pixel inputImg.
  47. int outSumB = 0;
  48. int outSumG = 0;
  49. int outSumR = 0;
  50. for (size_t k = 0; k < maskSize; k++)
  51. {
  52. for (size_t l = 0; l < maskSize; l++)
  53. {
  54. // Calculate the current mask index.
  55. size_t maskIdx = (maskSize - 1 - k) + (maskSize - 1 - l) * maskSize;
  56. // Compute output pixel.
  57. size_t maskLX = lx - maskSize / 2 + k;
  58. size_t maskLY = ly - maskSize / 2 + l;
  59. // Сheck if the current input pixel is in the local memory
  60. if (maskLX >= 0 && maskLX < SUB_SIZE && maskLY >= 0 && maskLY < SUB_SIZE)
  61. {
  62. outSumB += sub[maskLY][maskLX][0] * mask[maskIdx];
  63. outSumG += sub[maskLY][maskLX][1] * mask[maskIdx];
  64. outSumR += sub[maskLY][maskLX][2] * mask[maskIdx];
  65. }
  66. else
  67. {
  68. // Read the current input pixel from the global memory
  69. size_t maskX = x - maskSize / 2 + k;
  70. size_t maskY = y - maskSize / 2 + l;
  71. int offsetM = ((maskY * imgW) + maskX) * bytesPerPix;
  72. outSumB += inImg[offsetM] * mask[maskIdx];
  73. outSumG += inImg[offsetM + 1] * mask[maskIdx];
  74. outSumR += inImg[offsetM + 2] * mask[maskIdx];
  75. }
  76. }
  77. }
  78.  
  79. // Write output pixel.
  80. outImg[offset] = MinMaxVal(0, 255, outSumB);
  81. outImg[offset + 1] = MinMaxVal(0, 255, outSumG);
  82. outImg[offset + 2] = MinMaxVal(0, 255, outSumR);
  83. if (bytesPerPix == 4) // if we have alfa-channel
  84. outImg[offset + 3] = inImg[offset + 3];
  85. }

Вкратце работает так: создаем кеш размером SUB_SIZE на SUB_SIZE (в нашем случае 16 на 16) и записываем в него пиксели (строки 116-122). На краях рабочей группы озможны случаи, когда нам требуются пиксели, которые не закешированы и тогда мы извлекаем значения из глобальной памяти (строки 60-75). А дальше все как обычно: вычисляем сумму согласно коэффициентам фильтра и записываем ее в глобальный массив (строки 80-84).

Теперь самое интересное - результаты, которые довольно неожиданные. Использование кеша должно было улучшить производительность, по аналогии с прошлым разом, но получилось, что неоптимизированный метод (функция filterImage) работает быстрее, чем оптимизированный метод (функция filterImageCached), причем довольно заметно: 2.2-2.3мсек "неоптимизированного" против 2.7-2.8мсек "оптимизированного" кода для картинки 2048 на 1536. Я ожидал хотя бы незначительного, но прироста, а тут получилось значительное падение. Причины точно не ясны, но можно все же сделать несколько предположений. Например, аппаратный кеш видеокарты (у RTX 3060 он 3 мегабайта) вместе с быстрой памятью (192bit GDDR6) делает излишним дополнительные оптимизации в этом случае, но в то же время накладные расходы (как минимум дополнительные 4 сравнения в строке 60 и синхронизация тредов в строке 30) никуда не деваются. Тут, конечно, было бы интересно посмотреть на результаты с какой-то более слабой видеокартой c 64-битной шиной памяти - например GTX 1030. Так что не все так просто с оптимизациями и всегда надо очень тщательно проверять что и на каком оборудовании запускается.

Еще один нюанс с отладкой kernel-кода: оказывается, там можно использовать самый обычный printf(). Но, конечно, делать это надо с осторожностью, потому что обработка даже маленькой картинки 400 на 400 пикселей даст на выходе 16 тысяч трейсов в терминале, в которых можно потеряться.

Еще один важный нюанс при работе с OpenCL состоит в том, что при использовании рабочих групп непременно надо, чтобы размер входного массива был обязательно кратен размерам рабочей группы. Потому что я довольно долго не мог понять, почему неоптимизированная функция работает, а оптимизированная (и обработка разбита на группы тредов 16 на 16) не работает вообще. Оказалось, что enqueueNDRangeKernel возвращала мне значиние ошибки -54, что есть значение константы CL_INVALID_WORK_GROUP_SIZE. Об этом написали добрые люди на форуме

Error -54 is CL_INVALID_WORK_GROUP_SIZE - values in your “globalWorkSize” array are not divisible with values in your “localWorkSize” array, and that’s it (remember that global work size is really total number of threads along each dimension, and not the size of the “block” of threads along corresponding dimension).

Так что неудивительно, что OpenCL отказался обрабатывать мою картинку размером 1012 на 737 пикселей. После того, как я сменил разрешение на 1024 на 768, все заработало. Проверяйте коды ошибок - это может быть очень полезно.


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

Весь код можно посмотреть здесь.

суббота, 2 декабря 2023 г.

OpenCL: CMakeLists.txt проекта, "размывающий" (нижних частот) и "контурный" (верхних частот) фильтры изображений

Работа с изображениями - достаточно типичная задача для видекорарт и OpenCL. Ну точно более типичная, чем перемножение матриц. Я долго искал наглядный пример, чтобы с изображением и чтобы все работало и без лишних телодвижений типа установки каких-то непонятных или очень больших библиотек и в итоге так ничего не нашел, но потом наткнулся на пример работы с bmp-файлом, который подходит мне идеально: небольшой размер библиотеки (по сути все укладывается водин небольшой заголовочный файл - "BMP.h") и не требует никаких дополнительных действий. А еще я решил изучить, насколько удобно использовать cmake, встроенный в Visual Studio 2019 и править конфигурацию через CMakeLists.txt. Оказывается, что довольно удобно - удобнее, чем например руками каждый раз отдельно создавать Release и Debug конфигурации и для каждой из них создавать x86 и x64 варианты (итого 4 конфигурации как минимум). Так вот, у нас есть вот такой CMakeLists.txt:

  1. #
  2. cmake_minimum_required (VERSION 3.8)
  3.  
  4. # create project
  5. project ("OclFilterImg")
  6. # Add source to this project's executable.
  7. add_executable (OclFilterImg "OpenCL-filter-img.cpp" "BMP.h")
  8.  
  9. # OpencCL headers
  10. target_include_directories(OclFilterImg PRIVATE "$ENV{CUDA_PATH}/include")
  11.  
  12. # OpenCL library
  13. set(opencl_lib_folder "$ENV{CUDA_PATH}/lib")
  14.  
  15. # check 32 or 64 bits
  16. if(CMAKE_SIZEOF_VOID_P EQUAL 8)
  17. # 64 bits
  18. set(opencl_lib_folder "${opencl_lib_folder}/x64")
  19. elseif(CMAKE_SIZEOF_VOID_P EQUAL 4)
  20. # 32 bits
  21. set(opencl_lib_folder "${opencl_lib_folder}/Win32")
  22. endif()
  23.  
  24. # link OpenCL library
  25. target_link_libraries(OclFilterImg ${opencl_lib_folder}/OpenCL.lib)
  26.  
В таком виде оно корректно работоает под Visual Studio 2019 (если создавать с помощью визарда, встроенного в VS2019). Выглядит достаточно лаконично, а выбор между x86 и x64 делается в строке 16, где по размеру указателя на void определяется, какая у нас сборка: x86 или x64. Очень удобно!
Теперь собственно про саму фильтрацию изображения. На входе у нас будет вот такой bmp-файл:


Нам надо наложить на него сначала один фильтр, а потом на результат наложить еще один фильтр и потом еще один. Каждый пиксел это 3 байта RGB цвета + альфа-канал прозрачности, итого 32 бита на пиксел. Причем альфа-канала может и не быть, тогда получается 24 бита на пиксел. Библиотека поддерживает оба варианта и для удобства хранит картинку в виде массива байтов (unsigned char). Именно так мы и передаем изображение в код ядра, где уже через высоту и ширину картинки и количества байтов на пиксел ( 3 или 4 байта) вычисляем позицию пикселя. Первый фильтр у нас делает из цветного изображения монохромное путем усреднения цвета по всем трем RGB-компонентам:

  1. // converts an RBG image to grayscale
  2. __kernel void rgbToGray(
  3. __global unsigned char* inImg,
  4. __global unsigned char* outImg,
  5. const int bytesPerPix)
  6. {
  7. // Get work-item identifiers.
  8. int x = get_global_id(0);
  9. int y = get_global_id(1);
  10. int imgW = get_global_size(0);
  11.  
  12. int offset = ((y * imgW) + x) * bytesPerPix;
  13. unsigned char B = inImg[offset];
  14. unsigned char G = inImg[offset + 1];
  15. unsigned char R = inImg[offset + 2];
  16. unsigned char gray = (B + G + R) / 3;
  17.  
  18. outImg[offset] = gray;
  19. outImg[offset + 1] = gray;
  20. outImg[offset + 2] = gray;
  21. if (bytesPerPix == 4) // if we have alfa-channel
  22. outImg[offset + 3] = inImg[offset + 3];
  23. }
Особо стоит обратить внимание на строку 10, где мы получаем фактическую ширину картинки функцией get_global_size(0). Этот параметр мы фактически сами задаем при постановке в очередь исполнения кода ядра:

Первый параметр тут ширина картинки, а второй соответственно высота. Этот код по сути говорит, что надо вызвать для каждого пиксела картинки width * height по одному треду. Результирующая картинка имеет все те же RGB-поля с усредненными значениями. В результате мы получим вот такую картинку:


Теперь эту картинку мы пропустим через "размывающий" фильтр (в коде называется "фильтром нижних частот"), который модифицирует каждый пиксел с учетом окружающиъх его пикселей и усредняет его значение, используя вот такие коэффициенты фильтра 5 на 5:

  1. // Create a low-pass filter mask.
  2. const int lpMaskSize = 5;
  3. float lpMask[lpMaskSize][lpMaskSize] =
  4. {
  5. {.04,.04,.04,.04,.04},
  6. {.04,.04,.04,.04,.04},
  7. {.04,.04,.04,.04,.04},
  8. {.04,.04,.04,.04,.04},
  9. {.04,.04,.04,.04,.04},
  10. };
 Вот код ядра:

  1. int MinMaxVal(int min, int max, int val)
  2. {
  3. if (val < min)
  4. {
  5. return min;
  6. }
  7. else if (val > max)
  8. {
  9. return max;
  10. }
  11. return val;
  12. }
  13.  
  14. // This kernel function convolves an image input_image[imgWidth, imgHeight]
  15. // with a mask of size maskSize.
  16. __kernel void filterImage(
  17. __global unsigned char* inImg,
  18. __global unsigned char* outImg,
  19. const int bytesPerPix,
  20. const unsigned int maskSize,
  21. __constant float* mask)
  22. {
  23. // Get work-item identifiers.
  24. int x = get_global_id(0);
  25. int y = get_global_id(1);
  26. int imgW = get_global_size(0);
  27. int imgH = get_global_size(1);
  28. int offset = ((y * imgW) + x) * bytesPerPix;
  29.  
  30. // Check if the mask cannot be applied to the current pixel
  31. if (x < maskSize / 2
  32. || y < maskSize / 2
  33. || x >= imgW - maskSize / 2
  34. || y >= imgH - maskSize / 2)
  35. {
  36. outImg[offset] = 0;
  37. outImg[offset + 1] = 0;
  38. outImg[offset + 2] = 0;
  39. if (bytesPerPix == 4) // if we have alfa-channel
  40. outImg[offset + 3] = inImg[offset + 3];
  41. return;
  42. }
  43.  
  44. // Apply mask based on the neighborhood of pixel inputImg.
  45. int outSumB = 0;
  46. int outSumG = 0;
  47. int outSumR = 0;
  48. for (size_t k = 0; k < maskSize; k++)
  49. {
  50. for (size_t l = 0; l < maskSize; l++)
  51. {
  52. // Calculate the current mask index.
  53. size_t maskIdx = (maskSize - 1 - k) + (maskSize - 1 - l) * maskSize;
  54. // Compute output pixel.
  55. size_t xM = x - maskSize / 2 + k;
  56. size_t yM = y - maskSize / 2 + l;
  57. int offsetM = ((yM * imgW) + xM) * bytesPerPix;
  58. outSumB += inImg[offsetM] * mask[maskIdx];
  59. outSumG += inImg[offsetM + 1] * mask[maskIdx];
  60. outSumR += inImg[offsetM + 2] * mask[maskIdx];
  61. }
  62. }
  63.  
  64. // Write output pixel.
  65. outImg[offset] = MinMaxVal(0, 255, outSumB);
  66. outImg[offset + 1] = MinMaxVal(0, 255, outSumG);
  67. outImg[offset + 2] = MinMaxVal(0, 255, outSumR);
  68. if (bytesPerPix == 4) // if we have alfa-channel
  69. outImg[offset + 3] = inImg[offset + 3];
  70. }
Результат работы "размывающего" фильтра получается вот таким:


Теперь попробуем наложить "контурный" фильтр. Он использует тот же код ядра, что и предыдущий, но кэффициенты фильтра вот такие:

  1. // Create a high-pass filter mask.
  2. const int hpMaskSize = 5;
  3. float hpMask[hpMaskSize][hpMaskSize] =
  4. {
  5. {-1,-1,-1,-1,-1},
  6. {-1,-1,-1,-1,-1},
  7. {-1,-1,24,-1,-1},
  8. {-1,-1,-1,-1,-1},
  9. {-1,-1,-1,-1,-1},
  10. };
Результат работы такого "контурного фильтра" (в коде он называется "фильтром верхних частот") получается вот такой:


Интересный эффект, не правда ли? Если посмотреть на коэффициенты этого фильтра, то все можно обьяснить: если пиксел окружен такими же точно пикселями, то суммарное значение после прохождения такого фильтра будет равно нулю (24 пикселя с отрицательными коэффициентами плюс один этот пиксел с коэффициентом +24 дают в сумме ноль). А если есть вокруг отличные от этого пикселя, то значение будет больше нуля. То есть такой фильтр по сути будет давать значения больше нуля только на границах, например, какой-то заполненной геометрической фигуры.
Еще важно показать, как мы передаем результат работы предыдущего фильтра на вход следующего:

  1. void gpuProcess(const BMP& bmpIn, BMP& bmpOut)
  2. {
  3. const auto imgWidth = bmpIn.bmp_info_header.width;
  4. const auto imgHeight = bmpIn.bmp_info_header.height;
  5. const uint32_t bytesPP = bmpIn.bmp_info_header.bit_count / 8;
  6. cl::Buffer inImg(context, CL_MEM_READ_WRITE | CL_MEM_HOST_READ_ONLY | CL_MEM_COPY_HOST_PTR, imgWidth * imgHeight * bytesPP, (void*) bmpIn.data.data());
  7. cl::Buffer grayImg(context, CL_MEM_READ_WRITE | CL_MEM_HOST_READ_ONLY, imgWidth * imgHeight * bytesPP);
  8. cl::Buffer lpfImg(context, CL_MEM_READ_WRITE | CL_MEM_HOST_READ_ONLY, imgWidth * imgHeight * bytesPP);
  9. cl::Buffer lpMaskBuf(context, CL_MEM_READ_ONLY | CL_MEM_HOST_NO_ACCESS | CL_MEM_COPY_HOST_PTR, lpMaskSize * lpMaskSize * sizeof(lpMask[0]), lpMask);
  10. cl::Buffer hpfImg(context, CL_MEM_READ_WRITE | CL_MEM_HOST_READ_ONLY, imgWidth * imgHeight * bytesPP);
  11. cl::Buffer hpMaskBuf(context, CL_MEM_READ_ONLY | CL_MEM_HOST_NO_ACCESS | CL_MEM_COPY_HOST_PTR, hpMaskSize * hpMaskSize * sizeof(hpMask[0]), hpMask);
  12.  
  13. cl::Kernel grayKernel(program, "rgbToGray");
  14. grayKernel.setArg(0, inImg);
  15. grayKernel.setArg(1, grayImg);
  16. grayKernel.setArg(2, bytesPP);
  17.  
  18. cl::Kernel lpfKernel(program, "filterImage");
  19. lpfKernel.setArg(0, grayImg);
  20. lpfKernel.setArg(1, lpfImg);
  21. lpfKernel.setArg(2, bytesPP);
  22. lpfKernel.setArg(3, lpMaskSize);
  23. lpfKernel.setArg(4, lpMaskBuf);
  24.  
  25. cl::Kernel hpfKernel(program, "filterImage");
  26. hpfKernel.setArg(0, lpfImg);
  27. hpfKernel.setArg(1, hpfImg);
  28. hpfKernel.setArg(2, bytesPP);
  29. hpfKernel.setArg(3, hpMaskSize);
  30. hpfKernel.setArg(4, hpMaskBuf);
  31.  
  32. cl::CommandQueue queue(context, device);
  33. queue.enqueueNDRangeKernel(grayKernel, cl::NullRange, cl::NDRange(imgWidth, imgHeight));
  34. queue.enqueueNDRangeKernel(lpfKernel, cl::NullRange, cl::NDRange(imgWidth, imgHeight));
  35. queue.enqueueNDRangeKernel(hpfKernel, cl::NullRange, cl::NDRange(imgWidth, imgHeight));
  36. queue.enqueueReadBuffer(hpfImg, CL_TRUE, 0, bmpOut.data.size() * sizeof(bmpOut.data[0]), &bmpOut.data[0]);
  37. }
Весь код можно найти здесь.