OpenCL ? CUDA ? Используем GPU на полную мощь с VexCL

OpenCl logo CUDA logo
Когда нужны вычисления на GPU То стоит Выбор - какой SDK использовать. Среди реальных альтернатив CUDA и OpenCL. И выбор этот не простой - либо использовать открытое решение, которое запуститься практически на любой видеокарте, либо иметь выигрыш по скорости, ведь согласно исследованию на данный момент CUDA быстрее OpenCL на 16/67%.

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


VexCL

VexCL может использовать в качестве бекэнда как CUDA SDK, так и OpenCL C++ обертку, так и Boost.Compute - другую обертку над OpenCL C API.

VexCL берет на себя всю работу по инициализации SDK.
Модель памяти, которую предоставляет VexCL очень похожа на модель памяти OpenCL. Чтобы сделать любые вычисления необходимо скопировать данные в память GPU с помощью функции vex::copy. Далее сделать что-либо с данными - сложить/умнодить вектора сделать быстрое преобразование Фурье, или вызвать кастомное ядро и возвратить результаты на хост с помощью  vex::copy.
Вычисления с помощью VexCL
Вычисления с помощью VexCL
Складывать вектора стандартными способами скучно, поэтому будем писать свое ядро, что гораздо интереснее. Задача будет следующей, представим, что у нас есть граф с N вершинами, и у каждого графа есть M ребер. Нужно посчитать сумму ребер для каждой из вершин и перемножить со значением самой вершины. У нас будет 2 массива: массив вершин и массив ребер, в котором ребра будут лежать последовательно по M штук для каждой из вершин.

Настройка Xcode

Начнем. Для начала скачиваем VexCL
git clone https://github.com/ddemidov/vexcl.git

И устанавливаем буст (если он еще не установлен). 

brew install boost

Далее в настройках проекта Target -> Build Phases добавляем необходимые библиотеки (boost.thread, boost.filesystem, boost.system, boost.chrono и boost.date-time) и OpenCL.framework
Линкуем библиотеки Boost и OpenCL фреймворк для VexCL
Линкуем библиотеки Boost и OpenCL фреймворк
Прописываем пути, где искать заголовки буста и vexcl:
Пути к заголовкам boost и vexcl

Начинаем писать код.

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

#define VEXCL_BACKEND_OPENCL
//#define VEXCL_BACKEND_COMPUTE
//#define VEXCL_BACKEND_CUDA

#include <vexcl/vexcl.hpp>

Проверим, что в системе вообще есть GPU, которые мы можем использовать. Фильтры определяют какие вычислители мы будем использовать:

vex::Context ctx(vex::Filter::GPU);
 if (!ctx)
     throw std::runtime_error("No devices available.");

Далее создаем и заполняем вектора с данными:

const size_t N = 100000;
const size_t M = 1000;

std::vector<int> nodes(N);
for (size_t i = 0; i < N; ++i) {
  nodes[i] = i % 2 == 0;
}

std::vector<int> edges(N * M);
for (size_t i = 0; i < N * M; ++i) {
  edges[i] = rand() % 42;
}

std::vector<int> res(N);

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

for (size_t i = 0; i < N; i++) {
   int sum = 0;
   for (size_t j = i * M; j < i * M + M; ++j) {
       sum += edges[j];
   }
   res[i] = nodes[i] * sum;
}

Тут все просто. Теперь будем использовать GPU. Создадим инстансы класса vex::vector, который выделит память на GPU и будет ей оперировать, и скопируем туда данные из наших векторов. Вектора создаются в контекстах, :

vex::vector<int> device_nodes(ctx, N);
vex::copy(nodes, device_nodes);

vex::vector<int> device_edges(ctx, N * M);
vex::copy(edges, device_edges);

vex::vector<int> device_res(ctx, N);

И создадим ядро, с помощью которого  будем делать подсчеты. Ядро - это специальная функция, которую будут выполнять множество ядер графической карты. Макрос VEX_STRINGIZE_SOURCE создает эту функцию из содержимого своего параметра. После этого ядро будет скомпилировано, чтобы бэкэнд смог его использовать.

vex::backend::kernel my_kernel(ctx.queue(0),
                               VEX_STRINGIZE_SOURCE(
   kernel void my_kernel(ulong N, ulong M,
                         global int *nodes,
                         global int *edges,
                         global int *res)
   {
      for (size_t i = get_global_id(0);
           i < N;
           i += get_global_size(0))
      {
          int sum = 0;
          for (size_t j = i * M; j < i * M + M; ++j) {
              sum += edges[j];
          }
          res[i] = nodes[i] * sum;
      }
  }),
"my_kernel");

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

Дальше все что нам нужно сделать, это вызвать ядро как обычную функцию, и передать ей все параметры. vex::vector<T>.operator() возвращает указатель на буфер на GPU, который был выделен при инициализации:

my_kernel(ctx.queue(0), N, M, device_nodes(), device_edges(), device_res());

Это запустит процесс выполнения ядра на графической карте, после чего нужно будет получить результаты. Просто скопируем данные из device_res в вектор res:

vex::copy(device_res, res);

Результаты

Скорость выполнения каждого из вариантов:

CPU   : 301.921 ms
OpenMP: 84.7895 ms
OpenCL: 0.0063035 ms

Где OpenMP - это первый вариант, но цикл окнужен #pragma omp parallel for, как описано в предыдущей статье для автоматического распараллеливания. Выполняется на 4 ядерном i7 в 8 потоков.

Ложка дегтя

Вроде бы все оочень быстро, но не все так радужно. Эти замеры сделаны без учета времени на копирование данных на GPU и обратно. Для примера:

copy to device nodes: 0.160803 ms
copy to device edges: 64.4936 ms
OpenCL              : 0.0061901 ms
copy from device res: 33.5865 ms

То есть более 98 миллисекунд тратится на копирование данных. Так что очень быстро можно считать на OpenCL только если все данные уже лежат в памяти GPU, и копирование будет сведено к минимуму. Ну или расчеты настолько тяжелые, что будут сильно превышать по времени копирование всех данных.

Комментарии

Популярные сообщения из этого блога

Алгоритм NEAT. Эволюционирующие нейронные сети возрастающих топологий.

Цепи Маркова простыми словами. Пишем пирожки.