CUDA Reduction: Warp Unrolling (Школа)

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

// in is input array, out is where to store result, n is number of elements from in
// T is a float (32bit)
__global__ void kernel4(T *in, T *out, unsigned int n)

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

Код:

#include <stdlib.h>
#include <stdio.h>

#include "timer.h"
#include "cuda_utils.h"

typedef float T;

#define N_ (8 * 1024 * 1024)
#define MAX_THREADS 256
#define MAX_BLOCKS 64

#define MIN(x,y) ((x < y) ? x : y)
#define tid threadIdx.x 
#define bid blockIdx.x 
#define bdim blockDim.x
#define warp_size 32

unsigned int nextPow2( unsigned int x ) {
    --x;
    x |= x >> 1;
    x |= x >> 2;
    x |= x >> 4;
    x |= x >> 8;
    x |= x >> 16;
    return ++x;
}

void getNumBlocksAndThreads(int whichKernel, int n, int maxBlocks, int maxThreads, int &blocks, int &threads)
{
    if (whichKernel < 3) {
        threads = (n < maxThreads) ? nextPow2(n) : maxThreads;
        blocks = (n + threads - 1) / threads;
    } else {
        threads = (n < maxThreads*2) ? nextPow2((n + 1)/ 2) : maxThreads;
        blocks = (n + (threads * 2 - 1)) / (threads * 2);
    }
    if (whichKernel == 5)
        blocks = MIN(maxBlocks, blocks);
}

T reduce_cpu(T *data, int n) {
    T sum = data[0];
    T c = (T) 0.0;
    for (int i = 1; i < n; i++)
    {
        T y = data[i] - c;
        T t = sum + y;
        c = (t - sum) - y;
        sum = t;
    } 
    return sum;
}

__global__ void
kernel4(T *in, T *out, unsigned int n)
{
    __shared__ volatile T d[MAX_THREADS];

    unsigned int i = bid * bdim + tid;

    n >>= 1;
    d[tid] = (i < n) ? in[i] + in[i+n] : 0;
    __syncthreads ();

    for(unsigned int s = bdim >> 1; s > warp_size; s >>= 1) {
        if(tid < s)
            d[tid] += d[tid + s];
        __syncthreads ();
    }

    if (tid < warp_size) {
        if (n > 64) d[tid] += d[tid + 32];
        if (n > 32) d[tid] += d[tid + 16];
        d[tid] += d[tid + 8];
        d[tid] += d[tid + 4];
        d[tid] += d[tid + 2];
        d[tid] += d[tid + 1];
    }

    if(tid == 0)
        out[bid] = d[0];
}

int main(int argc, char** argv)
{
    T *h_idata, h_odata, h_cpu;
    T *d_idata, *d_odata;   

    struct stopwatch_t* timer = NULL;
    long double t_kernel_4, t_cpu;

    int whichKernel = 4, threads, blocks, N, i;
    if(argc > 1) {
        N = atoi (argv[1]);
        printf("N: %d\n", N);
    } else {
        N = N_;
        printf("N: %d\n", N);
    }

    getNumBlocksAndThreads (whichKernel, N, MAX_BLOCKS, MAX_THREADS, blocks, threads);

    stopwatch_init ();
    timer = stopwatch_create ();

    h_idata = (T*) malloc (N * sizeof (T));
    CUDA_CHECK_ERROR (cudaMalloc (&d_idata, N * sizeof (T)));
    CUDA_CHECK_ERROR (cudaMalloc (&d_odata, blocks * sizeof (T)));

    srand48(time(NULL));
    for(i = 0; i < N; i++)
        h_idata[i] = drand48() / 100000;
    CUDA_CHECK_ERROR (cudaMemcpy (d_idata, h_idata, N * sizeof (T), cudaMemcpyHostToDevice));

    dim3 gb(blocks, 1, 1);
    dim3 tb(threads, 1, 1);

    kernel4 <<<gb, tb>>> (d_idata, d_odata, N);
    cudaThreadSynchronize ();

    stopwatch_start (timer);

    kernel4 <<<gb, tb>>> (d_idata, d_odata, N);
    int s = blocks;
    while(s > 1) {
        threads = 0;
        blocks = 0;
        getNumBlocksAndThreads (whichKernel, s, MAX_BLOCKS, MAX_THREADS, blocks, threads);

        dim3 gb(blocks, 1, 1);
        dim3 tb(threads, 1, 1);

        kernel4 <<<gb, tb>>> (d_odata, d_odata, s);
        s = (s + threads * 2 - 1) / (threads * 2);
    }
    cudaThreadSynchronize ();

    t_kernel_4 = stopwatch_stop (timer);
    fprintf (stdout, "Time to execute unrolled GPU reduction kernel: %Lg secs\n", t_kernel_4);

    double bw = (N * sizeof(T)) / (t_kernel_4 * 1e9);   // total bits / time
    fprintf (stdout, "Effective bandwidth: %.2lf GB/s\n", bw);
    CUDA_CHECK_ERROR (cudaMemcpy (&h_odata, d_odata, sizeof (T), cudaMemcpyDeviceToHost));

    stopwatch_start (timer);
    h_cpu = reduce_cpu (h_idata, N);
    t_cpu = stopwatch_stop (timer);
    fprintf (stdout, "Time to execute naive CPU reduction: %Lg secs\n", t_cpu);

    if(abs (h_odata - h_cpu) > 1e-5)
        fprintf(stderr, "FAILURE: GPU: %f  CPU: %f\n", h_odata, h_cpu);
    else
        printf("SUCCESS: GPU: %f  CPU: %f\n", h_odata, h_cpu);
    return 0;
}

Мой первый вопрос: когда декларируешь

__shared__ volatile T d[MAX_THREADS];

Я хотел бы проверить мое понимание изменчивости. Volatile предотвращает неправильную оптимизацию компиляторами моего кода и обещает, что загрузка / сохранение завершаются через кеш, а не только через регистры (пожалуйста, исправьте меня, если ошиблись). Для сокращения, если суммы частичного сокращения все еще сохраняются в регистрах, почему это проблема?

Мой второй вопрос: когда делаю фактическое уменьшение деформации

    if (tid < warp_size) { // Final log2(32) = 5 strides
        if (n > 64) d[tid] += d[tid + 32];
        if (n > 32) d[tid] += d[tid + 16];
        d[tid] += d[tid + 8];
        d[tid] += d[tid + 4];
        d[tid] += d[tid + 2];
        d[tid] += d[tid + 1];
    }

Сумма редукции даст неверные результаты без условий (n > 64) и (n > 32). Результаты, которые я получаю:

FAILURE: GPU: 41.966557  CPU: 41.946209

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

Честно говоря, помощник моего учителя предложил это изменение, чтобы добавить условия (n > 64) и (n > 32), но не объяснил, почему это исправит код.

Поскольку n в моих исследованиях превышает 64, почему это обусловливает изменение результатов. У меня проблемы с поиском решения проблемы, потому что я не могу использовать функции печати, как в ЦП.

1 ответ

Решение

Давайте начнем с нескольких предварительных комментариев, прежде чем мы ответим на два ваших вопроса:

  • Я рекомендую вам прочесть учебник NVIDIA по каноническому сокращению
  • Сокращения, написанные таким образом, делают несколько предположений, одним из которых является то, что размер блока равен степени 2 (для "правильности").
  • Ваш код использует варп-синхронное программирование на финальной стадии сокращения. Похоже, вы знаете, что делаете, поэтому я не буду подробно описывать это, но это, безусловно, важно для понимания здесь. Вы можете погуглить его и получить описания, если это необходимо. Это имеет отношение к обсуждению ниже, но я не собираюсь называть его актуальность в каждой ситуации.

Хорошо, теперь ваши вопросы:

Я хотел бы проверить мое понимание изменчивости. Volatile предотвращает неправильную оптимизацию компиляторами моего кода и обещает, что загрузка / сохранение завершаются через кеш, а не только через регистры (пожалуйста, исправьте меня, если ошиблись). Для сокращения, если суммы частичного сокращения все еще сохраняются в регистрах, почему это проблема?

Что касается определения volatile Я хотел бы направить вас к руководству по программированию CUDA. Я видел сводные описания, относящиеся к этому как к предотвращению оптимизации регистра или к переупорядочению загрузок и хранилищ. Я предпочитаю первое и буду использовать это как рабочее определение.

Основная идея заключается в том, что volatile заставляет любую ссылку (чтение или запись) на эту переменную фактически перейти в подсистему памяти. Под этим я подразумеваю, что он будет выполнять чтение или запись и не будет пытаться использовать значение, ранее загруженное в регистр. Без этого спецификатора компилятор может загрузить значение один раз (например) из фактической ячейки памяти, а затем сохранить это значение (и любые его обновления) в регистре до тех пор, пока это будет сочтено целесообразным. Компиляторы делают это с прицелом на производительность. (Кроме того, обратите внимание, что здесь вы использовали слово "кеш". Я бы избегал такого использования здесь. Общая память не имеет кеша, вставленного между ней и механизмом загрузки / сохранения процессора.)

Без volatile в этом типе синхронного кодирования мы столкнемся с проблемой, если позволим компилятору "оптимизировать" (т.е. поддерживать) промежуточные значения в регистрах. Это в первую очередь происходит из-за связи между потоками. Чтобы понять почему, давайте посмотрим на последние 2 шага вашего окончательного сокращения:

    d[tid] += d[tid + 2];
    d[tid] += d[tid + 1];

Давайте рассмотрим только темы, чьи tid значения 0-1. На втором-последнем шаге нить 0 подхватит d[2] значение и добавить его к d[0] значение, а поток 1 будет забрать d[3] значение и добавить его к d[1] значение. На данный момент, если мы не используем volatile компилятор не обязан писать d[1] значение, накопленное потоком 1, возвращается в общую память. Это разрешено вести в реестре. Итак d[1] значение, которое видно в разделяемой памяти, не является "актуальным".

Теперь давайте перейдем к последнему шагу. На этом этапе поток 0 читает d[1] значение из общей памяти и добавляет его к d[0] значение. Но без volatile На предыдущем этапе мы увидели, что содержимое общей памяти d[1] больше не точны. OTOH, если мы используем volatile затем произойдет запись в общую память на предыдущем шаге, а на последнем шаге поток 0 выберет правильное значение при чтении d[1], Нить CUDA - это отдельная модель. Под этим я подразумеваю, что один поток не может напрямую обращаться к значениям, содержащимся в регистрах, принадлежащих другому потоку. Таким образом, межпотоковая связь на уровне деформации обычно осуществляется либо через общую память, либо с помощью операций деформации деформации.

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

Кроме того, в CUDA 9 такого рода синхронное программирование (более официально) не рекомендуется, вместо этого следует использовать совместные группы.

Сумма редукции даст неверные результаты без условий (n > 64) и (n > 32).

Эти условия в основном используются, потому что код разработан так, чтобы быть "правильным" для любой конфигурации блока, имеющей степень степени 2. Если мы предположим, что размер блока (количество потоков в блоке) равен степени 2 и больше 64, он должен быть, например, 128 или больше. Ваш n переменная начинается с размера блока, но затем умножается на 2:

n >>= 1;

Поэтому, если мы хотим убедиться в правильности этой строки кода:

d[tid] += d[tid + 32];

тогда мы должны применять эту операцию только тогда, когда размер блока потока равен 64 (как минимум), что все равно, что сказать, что n больше 64:

    if (n > 64) d[tid] += d[tid + 32];

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

int s = blocks;
while(s > 1) {
    threads = 0;
    blocks = 0;
    getNumBlocksAndThreads (whichKernel, s, MAX_BLOCKS, MAX_THREADS, blocks, threads);

Этот цикл в конечном итоге приводит к размеру блока меньше 128, что означает пропуск условий if, приводящих к поломке. (просто распечатайте threads переменная, во время этого цикла).

в соответствии с этим:

У меня проблемы с поиском решения проблемы, потому что я не могу использовать функции печати, как в ЦП.

Я не уверен, в чем проблема. printf должен работать изнутри кода ядра.

Совместно используемые переменные не могут иметь инициализацию как часть их объявления согласно этому ответу. Поэтому, если n < 64, мы добавляем к сумме некоторые случайные данные массива совместно используемой памяти, что в случае ошибки.

Другие вопросы по тегам