Подтвердить что ты не робот

Сокращение строк матрицы с помощью CUDA

Windows 7, NVidia GeForce 425M.

Я написал простой код CUDA, который вычисляет суммы строк матрицы. Матрица имеет одномерное представление (указатель на float).

Последовательная версия кода ниже (она имеет 2 петли, как и ожидалось):

void serial_rowSum (float* m, float* output, int nrow, int ncol) {
    float sum;
    for (int i = 0 ; i < nrow ; i++) {
        sum = 0;
        for (int j = 0 ; j < ncol ; j++)
            sum += m[i*ncol+j];
        output[i] = sum;
    }
}

Внутри кода CUDA я вызываю функцию ядра, подметая матрицу по строкам. Ниже фрагмент вызова ядра:

dim3 threadsPerBlock((unsigned int) nThreadsPerBlock); // has to be multiple of 32
dim3 blocksPerGrid((unsigned int) ceil(nrow/(float) nThreadsPerBlock)); 

kernel_rowSum<<<blocksPerGrid, threadsPerBlock>>>(d_m, d_output, nrow, ncol);

и функция ядра, которая выполняет параллельную сумму строк (все еще имеет цикл 1):

__global__ void kernel_rowSum(float *m, float *s, int nrow, int ncol) {

    int rowIdx = threadIdx.x + blockIdx.x * blockDim.x;

    if (rowIdx < nrow) {
        float sum=0;
        for (int k = 0 ; k < ncol ; k++)
            sum+=m[rowIdx*ncol+k];
        s[rowIdx] = sum;            
    }

}

Пока все хорошо. Серийный и параллельный (CUDA) результаты равны.

Все дело в том, что версия CUDA занимает почти в два раза больше времени для последовательного вычисления, даже если я изменяю параметр nThreadsPerBlock: я тестировал nThreadsPerBlock от 32 до 1024 (максимальное количество потоки на блок разрешены для моей карты).

IMO, размер матрицы достаточно велик, чтобы оправдать распараллеливание: 90,000 x 1,000.

Ниже я сообщаю время, прошедшее для серийной и параллельной версий, используя разные nThreadsPerBlock. Время, указанное в msec, в среднем по сравнению с 100 образцами:

Матрица: nrow = 90000 x ncol = 1000

Серийный: среднее время, прошедшее за выборку в msec (100 samples): 289.18.

CUDA (32 ThreadsPerBlock): среднее время, прошедшее за выборку в msec (100 samples): 497.11.

CUDA (1024 ThreadsPerBlock): среднее время, прошедшее за выборку в msec (100 samples): 699.66.

На всякий случай, версия с 32/1024 nThreadsPerBlock является самой быстрой/медленной.

Я понимаю, что при копировании с Host на Device есть какой-то накладные расходы, но, может быть, медленность заключается в том, что я не реализую самый быстрый код.

Поскольку я далек от того, чтобы быть экспертом CUDA:

Я кодирую самую быструю версию для этой задачи? Как я могу улучшить свой код? Могу ли я избавиться от цикла в функции ядра?

Любые мысли оценили.

РЕДАКТИРОВАТЬ 1

Хотя я описываю стандарт rowSum, меня интересует работа строк AND/OR, имеющих значения (0;1}, такие как rowAND/rowOR. Тем не менее, это не позволяет мне использовать cuBLAS умножить на трюк столбца тэга 1 COL, как это было предложено некоторыми комментаторами.

РЕДАКТИРОВАТЬ 2

Как предлагают пользователи других пользователей и здесь одобрены:

ЗАБУДЬТЕ О ПЫТАХ НАПИСАТЬ СВОИ СОБСТВЕННЫЕ ФУНКЦИИ, вместо этого используйте библиотеку Thrust и придет волшебство.

4b9b3361

Ответ 1

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

Подход к ядру

Здесь очень хороший документ, в котором описывается, как оптимизировать стандартное параллельное сокращение. Стандартное сокращение можно разделить на 2 этапа.

  • Несколько блоков потоков сокращают одну часть данных;
  • Один блок потоков уменьшается от результата этапа 1 до конечного 1 элемента.

Для вашей проблемы с несколькими сокращениями (уменьшить ряды мат) достаточно только этапа 1. Идея состоит в том, чтобы уменьшить 1 строку на блок потока. Для дополнительных соображений, таких как многострочный блок потока или 1 строка на несколько блоков потоков, вы можете обратиться к бумаге, предоставленной @Novak. Это может улучшить производительность, особенно для матриц с плохой формой.

Упорный подход

Общее многократное сокращение может быть выполнено через thrust::reduction_by_key через несколько минут. Здесь вы можете найти некоторые обсуждения Определение наименьшего элемента и его позиции в каждом столбце матрицы с помощью CUDA Thrust.

Однако thrust::reduction_by_key не предполагает, что каждая строка имеет одинаковую длину, поэтому вы получите штраф за производительность. Другое сообщение Как нормализовать столбцы матрицы в CUDA с максимальной производительностью? дает сравнение профилей между thrust::reduction_by_key и cuBLAS-подходом по сумме строк. Это может дать вам общее представление о производительности.

Подход cuBLAS

Сумма строк/столбцов матрицы A можно рассматривать как умножение матрицы-вектора, где элементы вектора являются единственными. он может быть представлен следующим кодом matlab.

y = A * ones(size(A,2),1);

где y - сумма строк A.

cuBLAS libary обеспечивает высокопроизводительную функцию умножения матричных векторов cublas<t>gemv() для этой операции.

Результат синхронизации показывает, что эта процедура только на 10 ~ 50% медленнее, чем просто читать все элементы A один раз, что можно рассматривать как теоретический верхний предел производительности для этой операции.

Ответ 2

Если это объем (суммирование строк) операций, которые вам нужно делать с этими данными, я бы не ожидал значительного выигрыша от графического процессора. У вас есть ровно одна арифметическая операция для каждого элемента данных, и за это вы платите стоимость переноса этого элемента данных на GPU. И за пределами определенного размера проблемы (независимо от того, что требуется для поддержания работы машины) вы не получаете дополнительной выгоды от более крупных размеров проблем, так как арифметическая интенсивность равна O (n).

Так что это не особо захватывающая проблема для решения на GPU.

Но, как указали когти, у вас есть проблема коалесценции в том, как вы ее создали, что еще больше замедлит работу. Давайте рассмотрим небольшой пример:

    C1  C2  C3  C4
R1  11  12  13  14
R2  21  22  23  24
R3  31  32  33  34
R4  41  42  43  44

Выше представлен простой иллюстративный пример небольшой части вашей матрицы. Память машинных данных такова, что элементы (11), (12), (13) и (14) сохраняются в соседних ячейках памяти.

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

Нам нужно подумать о выполнении вашего кода с точки зрения warp, то есть 32 потока, выполняемых в режиме блокировки. Что делает ваш код? Какие элементы он извлекает (запрашивает) на каждом шаге/инструкции? Давайте взглянем на эту строку кода:

        sum+=m[rowIdx*ncol+k];

Смежные потоки в warp имеют смежные (то есть последовательные) значения для rowIdx, поскольку вы создали эту переменную. Поэтому, когда k= 0, какой элемент данных запрашивается каждым потоком, когда мы пытаемся получить значение m[rowIdx*ncol+k]?

В блоке 0 поток 0 имеет rowIdx of 0. В потоке 1 есть rowIdx из 1 и т.д. Таким образом, значения, запрашиваемые каждым потоком в этой инструкции, следующие:

Thread:   Memory Location:    Matrix Element:
     0      m[0]                   (11)
     1      m[ncol]                (21)
     2      m[2*ncol]              (31)
     3      m[3*ncol]              (41)

Но это не объединенный доступ! Элементы (11), (21) и т.д. Не смежны в памяти. Для совместного доступа мы хотели бы, чтобы строка Matrix Element читалась следующим образом:

Thread:   Memory Location:    Matrix Element:
     0      m[?]                   (11)
     1      m[?]                   (12)
     2      m[?]                   (13)
     3      m[?]                   (14)

Если вы затем работаете назад, чтобы определить, что должно быть в значении ?, вы придумаете инструкцию примерно так:

        sum+=m[k*ncol+rowIdx];

Это даст совлокальный доступ, но он не даст вам правильного ответа, потому что теперь мы суммируем столбцы матрицы вместо строк матрицы. Мы можем исправить это, реорганизовывая хранилище данных, чтобы оно было в основном порядке столбцов, а не в строгом порядке. (Вы должны иметь возможность google, что для идей, правильно?) Концептуально это эквивалентно транспонированию вашей матрицы m. Независимо от того, подходит ли вам это для вас или нет, вы не попадаете в сферу своего вопроса, как я вижу, и не проблема CUDA. Это может быть просто для вас, поскольку вы создаете матрицу на хосте или передаете матрицу от хоста к устройству. Но, в общем, я не знаю способа суммирования строк матрицы со 100% -ным совместным доступом, если матрица хранится в строчном порядке. (Вы можете прибегнуть к последовательности сокращений строк, но это выглядит мне больно.)

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

И, да, то, что я здесь излагаю, по-прежнему сохраняет цикл в ядре.

В качестве дополнительного комментария я бы предложил разделить части копии данных и отдельные части ядра (вычислять). Я не могу сказать из вашего вопроса, планируете ли вы синхронизацию ядра или всего (GPU), включая копии данных. Если вы копируете копии данных отдельно, вы можете обнаружить, что только время копирования данных превышает ваше время процессора. Любые усилия по оптимизации кода CUDA не повлияют на время копирования данных. Это может быть полезной точкой данных, прежде чем тратить много времени на это.

Ответ 3

Сокращение строк матрицы можно решить, используя CUDA Thrust тремя способами (они могут быть не единственными, но обращение к этой точке выходит за рамки). Как также признано одним и тем же OP, использование CUDA Thrust предпочтительнее для такой проблемы. Кроме того, возможен подход с использованием cuBLAS.

ПОДХОД № 1 - reduce_by_key

Это подход, предложенный на этом странице примера тяги. Он включает в себя вариант с использованием make_discard_iterator.

ПОДХОД №2 - transform

Это подход, предложенный Робертом Кровелла в CUDA Thrust: reduce_by_key только для некоторых значений в массиве, основанных на значениях в "ключевом" массиве.

ПОДХОД № 3 - inclusive_scan_by_key

Это подход, предложенный Эриком в Как нормализовать столбцы матрицы в CUDA с максимальной производительностью?.

ПОДХОД # 4 - cublas<t>gemv

Он использует cuBLAS gemv для умножения соответствующей матрицы на столбец 1.

ПОЛНЫЙ КОД

Вот код, сжимающий два подхода. Файлы Utilities.cu и Utilities.cuh находятся здесь здесь и опущены здесь. TimingGPU.cu и TimingGPU.cuh поддерживаются здесь и также опущены.

#include <cublas_v2.h>

#include <thrust/host_vector.h>
#include <thrust/device_vector.h>
#include <thrust/generate.h>
#include <thrust/reduce.h>
#include <thrust/functional.h>
#include <thrust/random.h>
#include <thrust/sequence.h>

#include <stdio.h>
#include <iostream>

#include "Utilities.cuh"
#include "TimingGPU.cuh"

// --- Required for approach #2
__device__ float *vals;

/**************************************************************/
/* CONVERT LINEAR INDEX TO ROW INDEX - NEEDED FOR APPROACH #1 */
/**************************************************************/
template <typename T>
struct linear_index_to_row_index : public thrust::unary_function<T,T> {

    T Ncols; // --- Number of columns

    __host__ __device__ linear_index_to_row_index(T Ncols) : Ncols(Ncols) {}

    __host__ __device__ T operator()(T i) { return i / Ncols; }
};

/******************************************/
/* ROW_REDUCTION - NEEDED FOR APPROACH #2 */
/******************************************/
struct row_reduction {

    const int Ncols;    // --- Number of columns

    row_reduction(int _Ncols) : Ncols(_Ncols) {}

    __device__ float operator()(float& x, int& y ) {
        float temp = 0.f;
        for (int i = 0; i<Ncols; i++)
            temp += vals[i + (y*Ncols)];
        return temp;
    }
};

/**************************/
/* NEEDED FOR APPROACH #3 */
/**************************/
template<typename T>
struct MulC: public thrust::unary_function<T, T>
{
    T C;
    __host__ __device__ MulC(T c) : C(c) { }
    __host__ __device__ T operator()(T x) { return x * C; }
};

/********/
/* MAIN */
/********/
int main()
{
    const int Nrows = 5;     // --- Number of rows
    const int Ncols = 8;     // --- Number of columns

    // --- Random uniform integer distribution between 10 and 99
    thrust::default_random_engine rng;
    thrust::uniform_int_distribution<int> dist(10, 99);

    // --- Matrix allocation and initialization
    thrust::device_vector<float> d_matrix(Nrows * Ncols);
    for (size_t i = 0; i < d_matrix.size(); i++) d_matrix[i] = (float)dist(rng);

    TimingGPU timerGPU;

    /***************/
    /* APPROACH #1 */
    /***************/
    timerGPU.StartCounter();
    // --- Allocate space for row sums and indices
    thrust::device_vector<float> d_row_sums(Nrows);
    thrust::device_vector<int> d_row_indices(Nrows);

    // --- Compute row sums by summing values with equal row indices
    //thrust::reduce_by_key(thrust::make_transform_iterator(thrust::counting_iterator<int>(0), linear_index_to_row_index<int>(Ncols)),
    //                    thrust::make_transform_iterator(thrust::counting_iterator<int>(0), linear_index_to_row_index<int>(Ncols)) + (Nrows*Ncols),
    //                    d_matrix.begin(),
    //                    d_row_indices.begin(),
    //                    d_row_sums.begin(),
    //                    thrust::equal_to<int>(),
    //                    thrust::plus<float>());

    thrust::reduce_by_key(
                thrust::make_transform_iterator(thrust::make_counting_iterator(0), linear_index_to_row_index<int>(Ncols)),
                thrust::make_transform_iterator(thrust::make_counting_iterator(0), linear_index_to_row_index<int>(Ncols)) + (Nrows*Ncols),
                d_matrix.begin(),
                thrust::make_discard_iterator(),
                d_row_sums.begin());

    printf("Timing for approach #1 = %f\n", timerGPU.GetCounter());

    // --- Print result
    for(int i = 0; i < Nrows; i++) {
        std::cout << "[ ";
        for(int j = 0; j < Ncols; j++)
            std::cout << d_matrix[i * Ncols + j] << " ";
        std::cout << "] = " << d_row_sums[i] << "\n";
    }

    /***************/
    /* APPROACH #2 */
    /***************/
    timerGPU.StartCounter();
    thrust::device_vector<float> d_row_sums_2(Nrows, 0);
    float *s_vals = thrust::raw_pointer_cast(&d_matrix[0]);
    gpuErrchk(cudaMemcpyToSymbol(vals, &s_vals, sizeof(float *)));
    thrust::transform(d_row_sums_2.begin(), d_row_sums_2.end(), thrust::counting_iterator<int>(0),  d_row_sums_2.begin(), row_reduction(Ncols));

    printf("Timing for approach #2 = %f\n", timerGPU.GetCounter());

    for(int i = 0; i < Nrows; i++) {
        std::cout << "[ ";
        for(int j = 0; j < Ncols; j++)
            std::cout << d_matrix[i * Ncols + j] << " ";
        std::cout << "] = " << d_row_sums_2[i] << "\n";
    }

    /***************/
    /* APPROACH #3 */
    /***************/

    timerGPU.StartCounter();
    thrust::device_vector<float> d_row_sums_3(Nrows, 0);
    thrust::device_vector<float> d_temp(Nrows * Ncols);
    thrust::inclusive_scan_by_key(
                thrust::make_transform_iterator(thrust::make_counting_iterator(0), linear_index_to_row_index<int>(Ncols)),
                thrust::make_transform_iterator(thrust::make_counting_iterator(0), linear_index_to_row_index<int>(Ncols)) + (Nrows*Ncols),
                d_matrix.begin(),
                d_temp.begin());
    thrust::copy(
                thrust::make_permutation_iterator(
                        d_temp.begin() + Ncols - 1,
                        thrust::make_transform_iterator(thrust::make_counting_iterator(0), MulC<int>(Ncols))),
    thrust::make_permutation_iterator(
                        d_temp.begin() + Ncols - 1,
                        thrust::make_transform_iterator(thrust::make_counting_iterator(0), MulC<int>(Ncols))) + Nrows,
                d_row_sums_3.begin());

    printf("Timing for approach #3 = %f\n", timerGPU.GetCounter());

    for(int i = 0; i < Nrows; i++) {
        std::cout << "[ ";
        for(int j = 0; j < Ncols; j++)
            std::cout << d_matrix[i * Ncols + j] << " ";
        std::cout << "] = " << d_row_sums_3[i] << "\n";
    }

    /***************/
    /* APPROACH #4 */
    /***************/
    cublasHandle_t handle;

    timerGPU.StartCounter();
    cublasSafeCall(cublasCreate(&handle));

    thrust::device_vector<float> d_row_sums_4(Nrows);
    thrust::device_vector<float> d_ones(Ncols, 1.f);

    float alpha = 1.f;
    float beta  = 0.f;
    cublasSafeCall(cublasSgemv(handle, CUBLAS_OP_T, Ncols, Nrows, &alpha, thrust::raw_pointer_cast(d_matrix.data()), Ncols, 
                               thrust::raw_pointer_cast(d_ones.data()), 1, &beta, thrust::raw_pointer_cast(d_row_sums_4.data()), 1));

    printf("Timing for approach #4 = %f\n", timerGPU.GetCounter());

    for(int i = 0; i < Nrows; i++) {
        std::cout << "[ ";
        for(int j = 0; j < Ncols; j++)
            std::cout << d_matrix[i * Ncols + j] << " ";
        std::cout << "] = " << d_row_sums_4[i] << "\n";
    }

    return 0;
}

РЕЗУЛЬТАТЫ ВРЕМЕНИ (проверены на Kepler K20c)

Matrix size       #1     #1-v2     #2     #3     #4     #4 (no plan)
100  x 100        0.63   1.00     0.10    0.18   139.4  0.098
1000 x 1000       1.25   1.12     3.25    1.04   101.3  0.12
5000 x 5000       8.38   15.3     16.05   13.8   111.3  1.14

 100 x 5000       1.25   1.52     2.92    1.75   101.2  0.40    

5000 x 100        1.35   1.99     0.37    1.74   139.2  0.14

Похоже, что подходы №1 и №3 превосходят подход № 2, за исключением случаев с небольшим числом столбцов. Однако лучшим подходом является подход № 4, который значительно более удобен, чем другие, при условии, что время, необходимое для создания плана, может быть амортизировано во время вычислений.