Главная страница
Навигация по странице:

  • 3.3. Атомарные операции

  • 3.4. NVIDIA© Visual Profiler

  • 3.5. Базовые алгоритмы с использованием CUDA

  • Методичка (CUDA) - черновик. Введение cuda (Compute Unified Device Architecture)Ошибка! Источник ссылки не найден


    Скачать 1.06 Mb.
    НазваниеВведение cuda (Compute Unified Device Architecture)Ошибка! Источник ссылки не найден
    Дата09.07.2021
    Размер1.06 Mb.
    Формат файлаpdf
    Имя файлаМетодичка (CUDA) - черновик.pdf
    ТипДокументы
    #223817
    страница3 из 4
    1   2   3   4
    3.1. Функции синхронизации нитей внутри блока
    К функциям синхронизации нитей внутри блока относятся следующие 4 функции –
    __syncthreads
    ,
    __syncthreads_count
    , int
    __syncthreads_and
    ,
    __syncthreads_or
    . Описание функций сведено в таблицу 3.1.
    Таблица 3.1
    Функции синхронизации нитей внутри блока
    Функция
    Compute
    Capability
    Возвращаемое значение
    1
    2
    3
    void __syncthreads()
    1.0 и новее
    – int __syncthreads_count(int pred- icate)
    2.0 и новее
    Число нитей, для кото- рых выражение predicate не равно 0 int __syncthreads_and(int predi- cate)
    2.0 и новее
    Не нулевое значение только в том случае, если

    25 значение predicate для всех нитей блока не рав- но 0 int __syncthreads_or(int predi- cate)
    2.0 и новее
    Не нулевое значение только в том случае, если значение predicate хотя бы для одной нити блока не равно 0
    3.2. Варп
    Понятие варпа (warp) в архитектуре CUDA является одним из ключевых наравне с такими понятиями как нить, блок и решетка блоков. Варп представ- ляет собой блок из 32 нитей мультипроцессора GPU, которые выполняют одну и ту же инструкцию, но каждый над своим блоком данных, т.е. фактически варп определяет минимальное число нитей, которые будут работать в один момент времени. Тот факт, что 32 нити работают синхронно, при правильном проекти- ровании алгоритма (с учетом разбиения блоков записи и чтения в/из глобаль- ной памяти по 32 нити) позволяет избавиться от операции
    __syncthreads()
    , обеспечивающей барьерную синхронизацию внутри блока потоков.
    Рассмотрим следующий пример. Допустим, имеется некоторый массив длиной elementCount
    (тип данных – int
    ). Требуется написать ядро, которое вычисляет среднее арифметическое двух соседних элементов и сохраняет ре- зультат в целочисленном виде. Схема стандартного подхода представлена на рисунке 3.1, цифрами на рисунке обозначены индексы элементов, которые за- действуются.
    0 1
    2 3
    4 5
    6 7
    8 9
    10 11 0
    avg avg avg avg avg avg
    1 2
    3 4
    5
    Рис. 3.1. Схема вычисления среднего арифметического
    В представленном ниже алгоритме предполагается, что elementCount де- лится на 2. Код ядра, вычисляющего среднее арифметическое, показан ниже:

    26
    __global__ void average( int *inElements, int *outElements, unsigned elementCount
    ) {
    // разделяемая память, используемая для сохранения данных
    __shared__ int elementsBlock[512*2];
    // вычисляем смещение для текущего блока const int baseOffsetIn = blockIdx.x * blockDim.x * 2; const int baseOffsetOut = blockIdx.x * blockDim.x;
    // сохраняем входной массив if ( ( baseOffsetIn + threadIdx.x ) < elementCount ) elementsBlock[threadIdx.x] = inElements[baseOffsetIn + threadIdx.x]; if ((baseOffsetIn+threadIdx.x+blockDim.x) < elementCount ) elementsBlock[threadIdx.x + blockDim.x] = inElements[baseOffsetIn + threadIdx.x + blockDim.x];
    // синхронизация данных для гарантии того, что все элементы
    // были загружены
    __syncthreads();
    // вычисляем среднее арифметическое и сохраняем данные if ( (baseOffsetOut + threadIdx.x) < (elementCount >> 1) ) outElements[baseOffsetOut + threadIdx.x] =
    ( elementsBlock[threadIdx.x * 2] + elementsBlock[threadIdx.x * 2 + 1] ) >> 1;
    }
    Код запуска ядра выглядит следующим образом: dim3 threads(512); dim3 blocks( ( elementCount + 1023 ) / ( 2 * 512 ) ); average<<>>( inElements, outElements, elementCount
    );
    В конфигурации запуска ядра задается 512 нитей на блок (переменная threads
    ) и динамически вычисляется число блоков (переменная blocks
    ). При этом выражение
    ( elementCount + 1023 ) / ( 2 * 512 )
    гарантирует пра- вильное число блоков при условии, что остаток от деления elementCount на
    1024 не равен нулю. Таким образом, весь массив разбивается на блоки по 1024 элемента в каждом, при этом каждая нить обрабатывает по 2 соседних элемен-

    27 та. Для соблюдения условия формирования транзакций (см. подраздел 2.2) чте- ние 1024 элементов разбивается на 2 этапа – последовательно по 512 элементов с сохранением результата в разделяемую память (переменная elementsBlock
    ).
    Вызов функции
    __syncthreads()
    гарантирует, что все 1024 элемента бы- ли загружены и размещены в разделяемой памяти. Если убрать функцию син- хронизации, существует не нулевая вероятность получить некорректный ре- зультат. После синхронизации осуществляется расчет среднего арифметическо- го и сохранение результата в заданную позицию.
    Если задействовать свойства варпа, можно реорганизовать ядро таким об- разом, чтобы функция
    __syncthreads()
    стала лишней. Преобразуем загрузку элементов следующим образом: вместо блоков 2 по 512 элементов, будем за- гружать блоками 2 по 32 элемента (в соответствии с размером варпа). То есть первых 32 нити загружает последовательно 64 элемента данных, необходимых для вычисления среднего арифметического, следующие 32 нити еще 64 элемен- та, которые необходимы, и так далее. Разбиение на блоки по 32 в соответствии со свойством синхронности нитей варпа гарантирует, что все необходимые данные будут доступны без явной синхронизации. Получившийся код ядра представлен ниже:
    __global__ void average(int *inElements, int *outElements, unsigned elementCount) {
    // разделяемая память, используемая для сохранения данных
    __shared__ int elementsBlock[512*2];
    // индекс нити в реорганизованной структуре const int threadId = threadIdx.x & 31;
    // индекс варпа const int warpId = threadIdx.x >> 5;
    // вычисляем глобальное смещение для текущего блока const int baseOffsetIn = blockIdx.x * blockDim.x * 2; const int baseOffsetOut = blockIdx.x * blockDim.x;
    // вычисляем локальное смещение внутри блока const int localOffsetIn = warpId * 32 * 2 + threadId;
    // в данном примере выходное локальное смещение
    // совпадает с threadIdx.x,
    // однако в общем случае это может быть не справедливо const int localOffsetOut = warpId * 32 + threadId;
    // сохраняем входной массив

    28 if ( ( baseOffsetIn + localOffsetIn ) < elementCount ) elementsBlock[localOffsetIn] = inElements[baseOffsetIn + localOffsetIn]; if ( (baseOffsetIn + localOffsetIn + 32) < elementCount ) elementsBlock[localOffsetIn + 32] = inElements[baseOffsetIn + localOffsetIn + 32];
    // вычисляем среднее арифметическое и сохраняем данные if ((baseOffsetOut + localOffsetOut) < (elementCount>>1)) outElements[baseOffsetOut + localOffsetOut] =
    (elementsBlock[localOffsetOut*2] + elementsBlock[localOffsetOut*2+1])>>1;
    }
    3.3. Атомарные операции
    Атомарные операции представляют собой набор функций, обеспечиваю- щий гарантированное чтение – модификацию – сохранение одного 32- либо 64- битного слова в глобальной или разделяемой памяти. Сохранение при этом происходит по тому же адресу, из которого было произведено чтение данных. В отличие от стандартных операций загрузки, модификации и сохранения, в ато- марных операциях гарантируется:

    полное выполнение операции. Если, например, при чтении будет сгене- рировано исключение, операция модификации и сохранения выполнена не будет;

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

    атомарные операции доступны только при исполнении на устройстве
    (обязательна метка
    __device__
    либо
    __global__
    );

    не применимы к памяти выделенной как Mapped page-locked memory.
    На современных архитектурах графического процессора поддерживаются операции сложения, вычитания, операции над битами. Актуальный список ато- марных операций и их описание, а также версию Compute Capability, начиная с которой данная операция поддерживается, можно найти в официальной доку- ментации [1].

    29
    3.4. NVIDIA© Visual Profiler
    Профилировщик NVIDIA© Visual Profiler является основным средством для анализа производительности разработанного приложения с применением технологии CUDA. Данное приложение доступно для всех основных операци- онных системам (Windows, Linux, Max OS) и входит в стандартный набор
    NVIDIA© CUDA Toolkit.
    Настройка приложения (настройка сессии в терминологии CUDA) для под- готовки к анализу сводится лишь к указанию приложения, которое разработчик хочет протестировать, указанию рабочей директории и параметров командной строки при их наличии (рисунок 3.2). В примере на рисунке 3.2 осуществляется запуск приложения SampleTest.exe для тестирования и указана рабочая папка
    «K:\!Projects\».
    Результат работы профилировщика с настройками по-умолчанию для те- стового приложения (пример из раздела 3.2) показан на рисунке 3.3. Централь- ную часть экрана приложения занимает линия времени (timeline), на которой отображается последовательность запусков CUDA-функций и ядер с привязкой ко времени. Это позволяет быстро оценить, на какие действия тратится макси- мум времени, чтобы разработчик в дальнейшем мог оптимизировать это. Спра- ва при наведении курсора мыши на ядро на линии времени появляются полу- ченные свойства ядра. Данные свойства позволяют узнать полученную произ- водительность ядра (теоретическую и практическую), число регистров, прихо- дящихся на одну нить и другие параметры. Снизу экрана отображаются все функции-ядра, вызываемые в приложении и их краткая характеристика и, что самое важное, отображается реальное время работы ядра.

    30
    Рис. 3.2. Создание новой сессии в профилировщике
    Рис. 3.3. Результат работы профилировщика с настройками по-умолчанию
    После профилирования с настройками по-умолчанию через контекстное меню Run можно вызвать диалоговое окно, в котором указать характеристики, которые требуется измерить (рисунок 3.4).

    31
    Рис. 3.4. Диалоговое окно выбора метрик и событий для анализа
    Все метрики сгруппированы в следующие группы: память, инструкции, мультипроцессор, кэш, текстуры. Данные метрики позволяют оценить эффек- тивность работы с глобальной памятью (например, путем оценки теоретической и эффективной пропускной способности шины), работу мультипроцессора и другие характеристики. Более подробное описание всех метрик можно найти в официальной документации [].
    Важной составляющей профилировщика является встроенная система ана- лиза производительности приложения (вкладка Analysis). При выборе ядра, ко- торое требуется проанализировать, становится доступными следующие режимы анализа:

    «Kernel Performance Limiter» – определяет, является ли вычислительное устройство ограничителем производительности;

    32

    «Kernel Latency» – определяет, насколько качественно осуществляется сокрытие латентности операций работы с памятью и арифметических операций;

    «Kernel Compute» – оценка утилизации мультипроцессоров ядра ви- деокарты;

    «Kernel Memory» – анализ эффективной пропускной способности памя- ти;

    «Memory Access Pattern» – анализ шаблона обращения к памяти;

    «Divergent Execution» – анализ наличия дивергенции потоков;

    «Data Movement and Concurrency» – оценка эффективности перекрытия вычислительной нагрузки с функциями копирования данных
    (cudaMemcpy);

    «Compute Utilization» – анализ вычислительной нагрузки;

    «Kernel Performance» – оценка производительности ядра.
    После запуска анализа выводятся результаты работы. При этом в случае, если система обнаружила критические проблемы, выдается предупреждение об этом и рекомендации, как устранить данную проблему. На рисунке 3.5 пред- ставлен результат анализа ядра average после удаления из кода
    __syncthreads()
    в режиме «Kernel Latency».

    33
    Рис. 3.5. Результат анализа в режиме «Kernel Latency»
    Как видно из рисунка 3.5, используемая в тесте конфигурация запуска яв- ляется не эффективной, т.к. не позволяет качественно нагрузить имеющиеся мультипроцессоры видеокарты. При этом система рекомендовала увеличить число блоков, используемых в CUDA-ядре.
    3.5. Базовые алгоритмы с использованием CUDA
    К базовым относятся следующие алгоритмы: редукция и операция вычис- ления префиксной суммы (scan).
    Редукция – сумма значений всех элементов массива. Графически схема простейшего решения данной задачи представлена на рисунке 3.6. В качестве входного массива для алгоритма используется разделяемая память.
    0 1
    2 3
    4 5
    6 7
    0 1
    2 3
    Входной массив:
    Индекс нити:
    1 1
    5 3
    9 5
    13 7
    Входной массив:
    Синхронизация
    0 1
    Индекс нити:
    6 1
    5 3
    22 5
    13 7
    Входной массив:
    Синхронизация
    0
    Индекс нити:
    28 1
    5 3
    22 5
    13 7
    Входной массив:
    Синхронизация
    Результат
    работы
    Рис. 3.6. Алгоритм редукции. Базовая версия
    Код алгоритма редукции показан ниже:
    __global__ void reduceBase(int* inputData, int* outputData) { extern __shared__ int smemData[];
    // загружаем данные в разделяемую память const unsigned tid = threadIdx.x; smemData[tid] = inputData[blockIdx.x * blockDim.x+ threadIdx.x];

    34
    // синхронизируемся
    __syncthreads();
    // выполняем редукцию над элементами массива for( unsigned s = 1; s < blockDim.x; s <<= 1 ) { if( tid % ( s << 1 ) == 0 ) { smemData[tid] += smemData[tid + s];
    }
    // синхронизируемся, чтобы гарантировать
    // корректность входных данных
    // на следующей итерации цикла
    __syncthreads();
    }
    // сохраняем результат вычислений if ( tid == 0 ) outputData[blockIdx.x] = smemData[0];
    }
    Анализ кода на производительность указывает на несколько ключевых проблем в ядре:

    дивергенция потоков при выполнении условия: tid%(s<<1) == 0
    ;

    возможно нарушение доступа по банкам разделяемой памяти: smemData[tid] += smemData[tid + s]
    ;

    большое число вызовов функции синхронизации
    __syncthreads()
    Для решения описанных проблем представим схему вычисления как пока- зано на рисунке 3.7.

    35 0
    1 2
    3 4
    5 6
    7 0
    1 2
    3
    Входной массив:
    Индекс нити:
    4 6
    8 10 4
    5 6
    7
    Входной массив:
    Синхронизация
    Индекс нити:
    12 16 8
    10 4
    5 6
    7
    Входной массив:
    Синхронизация
    Индекс нити:
    28 1
    5 3
    4 5
    6 7
    Входной массив:
    Синхронизация
    Результат
    работы
    0 1
    0
    Рис. 3.7. Алгоритм редукции. Оптимизированная версия
    Последовательное обращение нитей к памяти позволяет решить проблему доступа к банкам разделяемой памяти. Кроме того появляется возможность сразу провести первое суммирование при чтении данных из глобальной памяти, тем самым убрав одну синхронизацию. Для этого достаточно правильной настройки конфигурации и чтение данных из глобальной памяти должно осу- ществляться с шагом blockDim.x
    , т.е. sum = inputData[threadIdx.x] + inputData[threadIdx.x + blockDim.x]
    . Данную оптимизацию читателю предлагается реализовать самостоятельно. Более детальное описание алгоритма и сравнение производительности можно найти в книге [4].
    Префиксной
    суммой называется массив следующего вида:
    }}
    {
    },...,
    {
    },
    {
    ,
    ,
    0
    {
    2 2
    1 0
    2 1
    0 1
    0 0

    n
    a
    a
    a
    a
    a
    a
    a
    a
    a
    a







    , где

    – любое арифметическое действие. Простейшая версия последовательной реализации алгоритма пре- фиксной суммы сводится к следующему: sum[0] = 0; for (int i = 0; i < size; i++) sum[i] = sum[i – 1]

    a[i – 1];
    В качестве примера в дальнейшем выбрано арифметическое действие – сумма. Параллельная реализация является не тривиальной задачей и сводится к

    36 двум подзадачам – построение дерева сумм (рисунок 3.8) и на основе получен- ного дерева построение префиксных сумм (рисунок 3.9).
    0 1
    2 3
    4 5
    6 7
    +
    +
    +
    +
    Входной массив:
    0 1
    2 5
    4 9
    6 13
    Входной массив:
    Синхронизация
    0 1
    2 6
    4 9
    6 22
    Входной массив:
    Синхронизация
    d = 2 0
    1 2
    6 4
    9 6
    28
    Входной массив:
    Синхронизация
    Результат
    работы
    +
    +
    +
    d = 0
    d = 1
    Рис. 3.8. Построение дерева сумм
    0 1
    2 6
    4 9
    6 28
    Входной массив:
    0 1
    2 6
    4 9
    6 0
    Входной массив:
    Синхронизация
    0 1
    2 0
    4 9
    6 6
    Входной массив:
    Синхронизация
    d = 1
    0 0
    2 1
    4 6
    6 15
    Входной массив:
    Синхронизация
    +
    +
    zero
    d = 0
    +
    d = 2
    0 0
    1 3
    6 10 15 21
    Входной массив:
    Синхронизация
    +
    +
    +
    +

    37
    Рис. 3.9. Построение префиксных сумм по дереву сумм
    Задачей первой части алгоритма является нахождение сумм элементов массива (по-аналогии с редукцией). После этого обнуляется последний элемент, содержащий сумму всех элементов. Далее начинается обработка пар, начиная с пары элементов, удаленных на половину длины блока и заканчивая парами со- седних элементов. На каждом шаге один из элементов пары копируется на ме- сто второго, а на место первого записывается сумма исходных элементов. Код функции-ядра, реализующий данный алгоритм, представлен ниже: const int blockSize = 256;
    __global__ void scan( float *inData, float *outData, int size)
    {
    __shared__ float temp[2 * blockSize]; int tid = threadIdx.x; int offset = 1;
    // чтение данных temp[tid] = inData[tid]; temp[tid + blockSize] = inData[tid + blockSize];
    // построение дерева сумм for (int d = n >> 1; d > 0; d >>= 1) {
    __syncthreads(); if ( tid < d ) { int ai = offset * ( 2 * tid + 1 ) – 1; int bi = offset * ( 2 * tid + 2 ) – 1; temp[bi] += temp[ai];
    } offset <<= 1;
    }
    // обнуление одного элемента if ( tid == 0 ) { temp[n – 1] = 0;
    }
    // построение префиксных сумм по дереву сумм for ( int d = 1; d < n; d <<= 1 ) {

    38 offset >>= 1;
    __syncthreads(); if ( tid < d ) { int ai = offset * ( 2 * tid + 1 ) – 1; int bi = offset * ( 2 * tid + 2 ) – 1; float t = temp[ai]; temp[ai] = temp[bi]; temp[bi] += t;
    }
    }
    __syncthreads(); outData[2 * tid] = temp[2 * tid]; outData[2 * tid + 1] = temp[2 * tid + 1];
    }
    Основной проблемой данного алгоритма являются постоянные конфликты доступа к банкам памяти вплоть до 16 порядка. Читателю предлагается само- стоятельно подумать над тем, как устранить данную проблему.
    1   2   3   4


    написать администратору сайта