Параллельное Программирование С Помощью Cuda. Часть 2. Аппаратное Обеспечение Графического Процессора И Шаблоны Параллельной Связи



Содержание Часть 1: Введение.

Часть 2. Аппаратное обеспечение графического процессора и шаблоны параллельной связи.

Часть 3. Основные алгоритмы графического процессора: сокращение, сканирование и гистограмма.

Часть 4. Основные алгоритмы графического процессора: компактное, сегментированное сканирование, сортировка.

Практическое применение некоторых алгоритмов.

Часть 5. Оптимизация программ на графическом процессоре.

Часть 6: Примеры распараллеливания последовательных алгоритмов.

Часть 7: Дополнительные темы по параллельному программированию, динамический параллелизм.



Шаблоны параллельного общения



Параллельное программирование с помощью CUDA. Часть 2. Аппаратное обеспечение графического процессора и шаблоны параллельной связи

Что такое параллельные вычисления? Не что иное, как набор потоков, решающих конкретную задачу совместно .

Ключевое слово здесь «кооператив» — для достижения сотрудничества необходимо использовать определенные механизмы связи между потоками.

При использовании CUDA связь происходит через память: потоки могут читать входные данные, изменять выходные данные или обмениваться «промежуточными» результатами.

В зависимости от того, как именно потоки взаимодействуют через память, существуют разные параллельные модели общения .

В предыдущей части в качестве простого примера использования CUDA рассматривалась задача преобразования цветного изображения в оттенки серого.

Для этого интенсивность каждого пикселя выходного полутонового изображения рассчитывалась по формуле I=A*пиксель.

R+B*пиксель.

G+C*пиксель.

B , Где А, Б, С - константы, пикс — соответствующий пиксель исходного изображения.

Графически этот процесс выглядит так:

Параллельное программирование с помощью CUDA. Часть 2. Аппаратное обеспечение графического процессора и шаблоны параллельной связи

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

Образец карта очень эффективен на GPU, а также просто выражается в терминах CUDA — достаточно запустить один поток для каждого входного элемента (как это было сделано в предыдущей части для задачи преобразования изображений).

Однако лишь малая часть проблем может быть решена с использованием только этого шаблона.

Если для расчета выходного значения с индексом я Если используется несколько входных элементов, такой шаблон называется собирать , и это может выглядеть так:

Параллельное программирование с помощью CUDA. Часть 2. Аппаратное обеспечение графического процессора и шаблоны параллельной связи

Или вот так:

Параллельное программирование с помощью CUDA. Часть 2. Аппаратное обеспечение графического процессора и шаблоны параллельной связи

Эффективность реализации этого паттерна на CUDA зависит от того, какие входные значения используются при вычислении выходных и их количества — лучше всего, когда используется небольшое количество последовательных элементов.

Обратный узор, разбрасывать - каждый входной элемент влияет на несколько (или один) выходных элементов, графически выглядит так же, как собирать Однако смысл меняется: теперь мы «стартуем» не с выходных элементов, для которых вычисляем их значение, а с входных, влияющих на значения тех или иных выходных элементов.

Довольно часто одну и ту же задачу можно решить в рамках шаблона.

собирать , так разбрасывать .

Например, если мы хотим усреднить 3 соседних входных элемента и записать их в выходной массив, мы можем:

  • запустить поток на каждом выходном элементе, где каждый поток будет усреднять значения 3 соседних входных элементов — собирать ;
  • или запустить поток для каждого входного элемента, где каждый поток будет добавлять 1/3 значения своего входного элемента к значению соответствующего выходного элемента — разбрасывать .

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

В случае 2D/3D изображений можно использовать различные типы этого шаблона, например двумерный трафарет фон Неймана:

Параллельное программирование с помощью CUDA. Часть 2. Аппаратное обеспечение графического процессора и шаблоны параллельной связи

или 2D-трафарет Мура:

Параллельное программирование с помощью CUDA. Часть 2. Аппаратное обеспечение графического процессора и шаблоны параллельной связи

Из-за этого ограничения шаблон трафарет обычно реализуется достаточно эффективно в рамках CUDA: достаточно запустить один поток для каждого выходного элемента, и поток сам прочитает нужные ему входные элементы.

При такой организации вычислений эффективность обеспечивается двумя факторами:

  1. Все данные, необходимые для одного потока, группируются в памяти (в случае одномерного массива — сплошной «кусок» памяти, в 2D случае — несколько участков памяти, расположенных на одинаковом расстоянии друг от друга).

  2. Значение некоторого входного элемента считывается несколько раз из соседних потоков (конкретное количество чтений зависит от выбранной маски) — появляется возможность «повторно использовать» данные, предоставленные нам CUDA — об этом речь пойдет далее в статье.



Аппаратное обеспечение графического процессора

Давайте посмотрим на высокоуровневую структуру аппаратного обеспечения графического процессора:

Параллельное программирование с помощью CUDA. Часть 2. Аппаратное обеспечение графического процессора и шаблоны параллельной связи

  • CUDA-совместимый графический процессор состоит из нескольких (обычно десятков) потоковые мультипроцессоры (потоковые мультипроцессоры), далее С.

    М.

    .

  • Каждый С.

    М.

    , в свою очередь, состоит из нескольких десятков простые/потоковые процессоры (SP) (обычные/потоковые процессоры), а точнее, Ядра CUDA (ядра CUDA).

    Эти ребята уже больше похожи на обычный CPU — у них свои регистры, кэш и т. д. Каждый С.

    М.

    тоже есть свой Общая память (разделяемая память) — это своего рода дополнительный кеш, который доступен всем SP и может использоваться как как кеш для часто используемых данных, так и для «общения» между потоками одного и того же блока CUDA.

  • Графический процессор также имеет собственную память, называемую память устройства , общий для всех потоков CUDA — с этим работают функции cudaMalloc И cudaMemcpy (его размеры любят измерять и школьники, и производители графических процессоров).



Соответствие модели CUDA и аппаратного обеспечения графического процессора.

Гарантии CUDA

Согласно модели CUDA, программист разбивает задачу на блоки, а блоки на потоки.

Как эти программные объекты сопоставляются с аппаратными блоками графического процессора, описанными выше?

Параллельное программирование с помощью CUDA. Часть 2. Аппаратное обеспечение графического процессора и шаблоны параллельной связи

  • Каждый блок будет полностью выполнен в выделенном для него месте.

    С.

    М.

    .

  • Распределение блоков по С.

    М.

    Он занимается графическим процессором, а не программистом.

  • Все потоки блокировки Икс будут разделены на группы, называемые коробит (так обычно говорят - перекосы), и сделаны на С.

    М.

    .

    Размер этих групп зависит от модели графического процессора, например для моделей с микроархитектурой.

    Ферми оно равно 32. Все потоки из одного варпа выполняются одновременно, занимая определенную часть ресурсов С.

    М.

    .

    Причём они либо выполняют одну и ту же инструкцию (но на разных данных), либо простаивают.

На основании всего этого CUDA предоставляет следующие гарантии:
  • Все потоки в определенном блоке будут выполняться за один раз.

    С.

    М.

    .

  • Все темы определенного ядра будет выполнен до того, как начнет выполняться следующее ядро.

КУДА не гарантирует Что:
  • Какой-то блок Икс будет выполнено до/после/одновременно с каким-либо блоком Да .

  • Какой-то блок Икс будет выполнено на каком-то конкретном С.

    М.

    З .



Синхронизация

Итак, перечислим основные механизмы синхронизации, предоставляемые CUDA:
  • Барьер — точка в коде ядра, при достижении которой поток может «пройти» дальше, только если все потоки из его квартала достигли этой точки.

    Еще раз: барьер позволяет синхронизировать только потоки один блок , и не все темы в принципе! Ограничение вполне естественное, ведь количество блоков, заданное программистом, может существенно превышать количество доступных.

    С.

    М.

    .

  • Атомарные операции - аналогично атомарным операциям ЦП, можно просмотреть полный список доступных операций здесь .

  • __threadfence — не совсем примитив синхронизации: по достижении этой инструкции поток может продолжить выполнение только после того, как все его манипуляции с памятью станут видны другим потокам — по сути, он заставляет поток сбросить кеш.



Основные принципы эффективного использования CUDA

  • Принцип увеличения соотношения (полезное время работы)/(время работы памяти) был рассмотрен в предыдущей статье.

    Значение дроби можно увеличить двумя способами — увеличить числитель, уменьшить знаменатель: то есть либо нужно выполнить больше работы, либо потратить меньше времени на операции с памятью.

    Помимо очевидного решения — чтобы по возможности сократить количество обращений к памяти, используются следующие принципы эффективного управления памятью:

    • Переместите часто используемые данные в более быструю память: поточную локальную память > общая блочная память > > общая память устройства > > память хоста.

      Таким образом, если несколько потоков в одном блоке используют одни и те же данные, скорее всего, имеет смысл переместить их в общую память блока.

    • Последовательный доступ к памяти: поскольку потоки в блоках фактически выполняются группами деформаций, если потоки в одной и той же деформации работают с данными, расположенными последовательно в памяти, CUDA сможет читать один большой фрагмент памяти для каждой инструкции.

      В противном случае, если потоки в варп-доступе к данным разбросаны по памяти, количество обращений к памяти увеличивается.

  • Уменьшение расхождения потоков: особенность CUDA заключается в том, что потоки в одной и той же деформации Всегда либо они выполняют одну и ту же инструкцию, либо простаивают. Таким образом, если код ядра содержит код типа
      
      
      
       

    if (threadIdx.x % 2 == 0) { .

    } .



    , то половина потоков деформации (с нечетным индексом) будет ждать, пока вторая половина не выполнит код внутри if. Поэтому подобных ситуаций следует избегать.



Пишем вторую программу на CUDA

Перейдем к практике.

В качестве примера, иллюстрирующего изложенную теорию, напишем программу, размывающую изображение.

по Гауссу .

Принцип работы следующий: значение каналов Р, Г, Б пикселей в выходном размытом изображении рассчитывается как взвешенная сумма значений каналов.

Р, Г, Б (соответственно) все пиксели исходного изображения в определенном трафарете:

Параллельное программирование с помощью CUDA. Часть 2. Аппаратное обеспечение графического процессора и шаблоны параллельной связи

Веса рассчитываются с использованием двумерного распределения Гаусса, но как именно это делается, для нашей задачи не очень важно.

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

Начнем со скелета программы: main.cpp

#include <chrono> #include <iostream> #include <cstring> #include <string> #include <opencv2/core/core.hpp> #include <opencv2/highgui/highgui.hpp> #include <opencv2/opencv.hpp> #include <vector_types.h> #include "openMP.hpp" #include "CUDA_wrappers.hpp" #include "common/image_helpers.hpp" void prepareFilter(float **filter, int *filterWidth, float *filterSigma) { static const int blurFilterWidth = 9; static const float blurFilterSigma = 2.; *filter = new float[blurFilterWidth * blurFilterWidth]; *filterWidth = blurFilterWidth; *filterSigma = blurFilterSigma; float filterSum = 0.f; const int halfWidth = blurFilterWidth/2; for (int r = -halfWidth; r <= halfWidth; ++r) { for (int c = -halfWidth; c <= halfWidth; ++c) { float filterValue = expf( -(float)(c * c + r * r) / (2.f * blurFilterSigma * blurFilterSigma)); (*filter)[(r + halfWidth) * blurFilterWidth + c + halfWidth] = filterValue; filterSum += filterValue; } } float normalizationFactor = 1.f / filterSum; for (int r = -halfWidth; r <= halfWidth; ++r) { for (int c = -halfWidth; c <= halfWidth; ++c) { (*filter)[(r + halfWidth) * blurFilterWidth + c + halfWidth] *= normalizationFactor; } } } void freeFilter(float *filter) { delete[] filter; } int main( int argc, char** argv ) { using namespace cv; using namespace std; using namespace std::chrono; if( argc != 2) { cout <<" Usage: blur_image imagefile" << endl; return -1; } Mat image, blurredImage, referenceBlurredImage; uchar4 *imageArray, *blurredImageArray; prepareImagePointers(argv[1], image, &imageArray, blurredImage, &blurredImageArray, CV_8UC4); int numRows = image.rows, numCols = image.cols; float *filter, filterSigma; int filterWidth; prepareFilter(&filter, &filterWidth, &filterSigma); cv::Size filterSize(filterWidth, filterWidth); auto start = system_clock::now(); cv::GaussianBlur(image, referenceBlurredImage, filterSize, filterSigma, filterSigma, BORDER_REPLICATE); auto duration = duration_cast<milliseconds>(system_clock::now() - start); cout<<"OpenCV time (ms):" << duration.count() << endl; start = system_clock::now(); BlurImageOpenMP(imageArray, blurredImageArray, numRows, numCols, filter, filterWidth); duration = duration_cast<milliseconds>(system_clock::now() - start); cout<<"OpenMP time (ms):" << duration.count() << endl; cout<<"OpenMP similarity:" << getEuclidianSimilarity(referenceBlurredImage, blurredImage) << endl; for (int i=0; i<4; ++i) { memset(blurredImageArray, 0, sizeof(uchar4)*numRows*numCols); start = system_clock::now(); BlurImageCUDA(imageArray, blurredImageArray, numRows, numCols, filter, filterWidth); duration = duration_cast<milliseconds>(system_clock::now() - start); cout<<"CUDA time full (ms):" << duration.count() << endl; cout<<"CUDA similarity:" << getEuclidianSimilarity(referenceBlurredImage, blurredImage) << endl; } freeFilter(filter); return 0; }

Точки:

  1. Читаем файл изображения, готовим указатели на исходное изображение и полученное размытое изображение.

    Функция подготовитьImagePointers остается прежним, при необходимости вы можете просмотреть его исходный код на bitbucket.

  2. Готовим фильтр Гаусса — то есть набор наших весов.

    Мы также запоминаем используемые параметры фильтра, чтобы затем передать их в OpenCV и получить образец размытого изображения для проверки корректности работы наших алгоритмов.

  3. Вызываем функцию размытия по Гауссу из OpenCV, сохраняем полученный образец и измеряем затраченное время.

  4. Вызываем функцию размытия по Гауссу, написанную с использованием OpenMP, измеряем затраченное время и сравниваем результат с образцом.

    Функция расчета сходства изображений getEuclidianSimilarity следующее: getEuclidianSimilarity

    double getEuclidianSimilarity(const cv::Mat& a, const cv::Mat& b) { double errorL2 = cv::norm(a, b, cv::NORM_L2); double similarity = errorL2 / (double) (a.rows * a.cols); return similarity; }

    По сути, он находит среднюю сумму квадратов разностей значений всех каналов всех пикселей в двух изображениях.

  5. Мы вызываем CUDA-версию размытия по Гауссу 4 раза, каждый раз измеряя затраченное время и сверяя результат с образцом.

    Зачем звонить 4 раза? Дело в том, что при самом первом вызове на инициализацию будет потрачено определенное количество времени — поэтому лучше запустить ее несколько раз и измерить время, затраченное на последующие вызовы.

OpenMP реализация алгоритма: openMP.hpp

#include <stdio.h> #include <omp.h> #include <vector_types.h> #include <vector_functions.h> void BlurImageOpenMP(const uchar4 * const imageArray,

Теги: #Параллельное программирование #GPGPU #cuda #Udacity
Вместе с данным постом часто просматривают: