CUDA Параллельный кросс продукт - PullRequest
0 голосов
/ 29 мая 2018

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

Вот особая проблема, которую я хочу решить с помощью параллельного программирования.У меня есть несколько одномерных массивов, в которых хранятся трехмерные векторы в этом формате -> [v0x, v0y, v0z, ... vnx, vny, vnz], где n - вектор, а x, y, z - соответствующие компоненты.

Предположим,Я хочу найти перекрестное произведение между векторами [v0, v1, ... vn] в одном массиве и соответствующими им векторами [v0, v1, ... vn] в другом массиве.

Расчет довольно прост без распараллеливания:

result[x] = vec1[y]*vec2[z] - vec1[z]*vec2[y];

result[y] = vec1[z]*vec2[x] - vec1[x]*vec2[z];

result[z] = vec1[x]*vec2[y] - vec1[y]*vec2[x];

У меня проблема с пониманием того, как реализовать распараллеливание CUDA для массивов, которые у меня есть в настоящее время.Поскольку каждое значение в векторе результатов является отдельным вычислением, я могу эффективно выполнить вышеуказанное вычисление для каждого вектора параллельно.Поскольку каждый компонент результирующего перекрестного произведения является отдельным расчетом, они также могут выполняться параллельно.Как мне настроить блоки и потоки / подумать о настройке потоков для такой проблемы?

1 Ответ

0 голосов
/ 29 мая 2018

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

Очень простая стратегия потока (стратегия потока отвечает на вопрос «что каждый поток будет делать или за что будет отвечать?») В любом преобразовании *Проблема типа 1004 * (в отличие от сокращение ) состоит в том, чтобы каждый поток отвечал за 1 выходное значение.Ваша проблема соответствует описанию преобразование - размер выходного набора данных соответствует порядку размера (ов) входных данных.

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

Если мы выберем стратегию потока с 1 выходомточка на поток (т. е. result[x] или result[y] или result[z], все вместе будет 3 выходными точками), тогда нам понадобится 3 потока для вычисления выходных данных каждого векторного перекрестного произведения.Если у нас будет достаточно векторов для умножения, у нас будет достаточно потоков, чтобы наша машина была «занята» и хорошо справлялась с сокрытием задержки.Как правило, ваша проблема начнет становиться интересной на графических процессорах, если число потоков составляет 10000 или более, поэтому это означает, что мы бы хотели, чтобы ваши одномерные векторы состояли примерно из 3000 трехмерных векторов или более.Давайте предположим, что это так.

Чтобы решить задачу повышения эффективности использования памяти, нашей первой задачей является загрузка ваших векторных данных из глобальной памяти.В идеале мы хотим, чтобы это было слитым , что примерно означает, что смежные потоки обращаются к соседним элементам в памяти.Мы также хотим, чтобы хранилища выходных данных были объединены, и наша стратегия потоков, заключающаяся в выборе одной выходной точки / одного векторного компонента для каждого потока, будет хорошо работать для поддержки этого.

Для эффективного использования памяти мы хотели быидеально загружать каждый элемент из глобальной памяти только один раз.Ваш алгоритм, естественно, включает в себя небольшое количество повторного использования данных .Повторное использование данных очевидно, поскольку вычисление result[y] зависит от vec2[z], а вычисление result[x] также зависит от vec2[z], чтобы выбрать только один пример.Поэтому типичная стратегия при повторном использовании данных состоит в том, чтобы сначала загрузить данные в общую память CUDA, а затем позволить потокам выполнять свои вычисления на основе данных в общей памяти.Как мы увидим, это позволяет нам довольно легко / удобно организовывать объединенные нагрузки из глобальной памяти, поскольку глобальная схема загрузки данных больше не тесно связана с потоками или использованием данных для вычислений.

Последняя задача - определить шаблон индексации, чтобы каждый поток выбирал нужные элементы из общей памяти для умножения вместе.Если мы посмотрим на ваш шаблон расчета, который вы изобразили в своем вопросе, мы увидим, что первая загрузка из vec1 следует шаблону смещения +1 (по модулю 3) из индекса, для которого вычисляется результат.Итак x -> y, y -> z и z -> x.Аналогичным образом мы видим +2 (по модулю 3) для следующей загрузки из vec2, другой шаблон +2 (по модулю 3) для следующей загрузки из vec1 и еще один шаблон +1 (по модулю 3) для окончательной загрузки из vec2.

Если мы объединим все эти идеи, мы сможем написать ядро, которое должно иметь в целом эффективные характеристики:

$ cat t1003.cu
#include <stdio.h>

#define TV1 1
#define TV2 2
const size_t N = 4096;    // number of 3D vectors
const int blksize = 192;  // choose as multiple of 3 and 32, and less than 1024
typedef float mytype;
//pairwise vector cross product
template <typename T>
__global__ void vcp(const T * __restrict__ vec1, const T * __restrict__ vec2, T * __restrict__ res, const size_t n){

  __shared__ T sv1[blksize];
  __shared__ T sv2[blksize];
  size_t idx = threadIdx.x+blockDim.x*blockIdx.x;
  while (idx < 3*n){ // grid-stride loop
    // load shared memory using coalesced pattern to global memory
    sv1[threadIdx.x] = vec1[idx];
    sv2[threadIdx.x] = vec2[idx];
    // compute modulo/offset indexing for thread loads of shared data from vec1, vec2
    int my_mod = threadIdx.x%3;   // costly, but possibly hidden by global load latency
    int off1 = my_mod+1;
    if (off1 > 2) off1 -= 3;
    int off2 = my_mod+2;
    if (off2 > 2) off2 -= 3;
    __syncthreads();
    // each thread loads its computation elements from shared memory
    T t1 = sv1[threadIdx.x-my_mod+off1];
    T t2 = sv2[threadIdx.x-my_mod+off2];
    T t3 = sv1[threadIdx.x-my_mod+off2];
    T t4 = sv2[threadIdx.x-my_mod+off1];
    // compute result, and store using coalesced pattern, to global memory
    res[idx] = t1*t2-t3*t4;
    idx += gridDim.x*blockDim.x;}  // for grid-stride loop
}

int main(){

  mytype *h_v1, *h_v2, *d_v1, *d_v2, *h_res, *d_res;
  h_v1  = (mytype *)malloc(N*3*sizeof(mytype));
  h_v2  = (mytype *)malloc(N*3*sizeof(mytype));
  h_res = (mytype *)malloc(N*3*sizeof(mytype));
  cudaMalloc(&d_v1,  N*3*sizeof(mytype));
  cudaMalloc(&d_v2,  N*3*sizeof(mytype));
  cudaMalloc(&d_res, N*3*sizeof(mytype));
  for (int i = 0; i<N; i++){
    h_v1[3*i]    = TV1;
    h_v1[3*i+1]  = 0;
    h_v1[3*i+2]  = 0;
    h_v2[3*i]    = 0;
    h_v2[3*i+1]  = TV2;
    h_v2[3*i+2]  = 0;
    h_res[3*i]   = 0;
    h_res[3*i+1] = 0;
    h_res[3*i+2] = 0;}
  cudaMemcpy(d_v1, h_v1, N*3*sizeof(mytype), cudaMemcpyHostToDevice);
  cudaMemcpy(d_v2, h_v2, N*3*sizeof(mytype), cudaMemcpyHostToDevice);
  vcp<<<(N*3+blksize-1)/blksize, blksize>>>(d_v1, d_v2, d_res, N);
  cudaMemcpy(h_res, d_res, N*3*sizeof(mytype), cudaMemcpyDeviceToHost);
  // verification
  for (int i = 0; i < N; i++) if ((h_res[3*i] != 0) || (h_res[3*i+1] != 0) || (h_res[3*i+2] != TV1*TV2)) { printf("mismatch at %d, was: %f, %f, %f, should be: %f, %f, %f\n", i, h_res[3*i], h_res[3*i+1], h_res[3*i+2], (float)0, (float)0, (float)(TV1*TV2)); return -1;}
  printf("%s\n", cudaGetErrorString(cudaGetLastError()));
  return 0;
}


$ nvcc t1003.cu -o t1003
$ cuda-memcheck ./t1003
========= CUDA-MEMCHECK
no error
========= ERROR SUMMARY: 0 errors
$

Обратите внимание, что я решил написать ядро ​​с использованием петля шага сетки .Это не очень важно для этого обсуждения и не так важно для этой проблемы, потому что я выбрал размер сетки, равный размеру проблемы (4096 * 3).Однако для гораздо больших размеров задач вы можете выбрать меньший размер сетки, чем общий размер проблемы, для некоторого возможного небольшого повышения эффективности.

Для такой простой задачи, как эта, довольно легко определить «оптимальность».Тем не менее, оптимальный сценарий будет долгим, чтобы загрузить входные данные (только один раз) и записать выходные данные.Если мы рассмотрим более крупную версию тестового кода выше, изменив N на 40960 (и не внося никаких других изменений), то общее количество прочитанных и записанных данных составит 40960 * 3 * 4 * 3 байта.Если мы профилируем этот код, а затем сравниваем с bandwidthTest в качестве прокси-сервера для пиковой достижимой пропускной способности памяти, мы наблюдаем:

$ CUDA_VISIBLE_DEVICES="1" nvprof ./t1003
==27861== NVPROF is profiling process 27861, command: ./t1003
no error
==27861== Profiling application: ./t1003
==27861== Profiling result:
            Type  Time(%)      Time     Calls       Avg       Min       Max  Name
 GPU activities:   65.97%  162.22us         2  81.109us  77.733us  84.485us  [CUDA memcpy HtoD]
                   30.04%  73.860us         1  73.860us  73.860us  73.860us  [CUDA memcpy DtoH]
                    4.00%  9.8240us         1  9.8240us  9.8240us  9.8240us  void vcp<float>(float const *, float const *, float*, unsigned long)
      API calls:   99.10%  249.79ms         3  83.263ms  6.8890us  249.52ms  cudaMalloc
                    0.46%  1.1518ms        96  11.998us     374ns  454.09us  cuDeviceGetAttribute
                    0.25%  640.18us         3  213.39us  186.99us  229.86us  cudaMemcpy
                    0.10%  255.00us         1  255.00us  255.00us  255.00us  cuDeviceTotalMem
                    0.05%  133.16us         1  133.16us  133.16us  133.16us  cuDeviceGetName
                    0.03%  71.903us         1  71.903us  71.903us  71.903us  cudaLaunchKernel
                    0.01%  15.156us         1  15.156us  15.156us  15.156us  cuDeviceGetPCIBusId
                    0.00%  7.0920us         3  2.3640us     711ns  4.6520us  cuDeviceGetCount
                    0.00%  2.7780us         2  1.3890us     612ns  2.1660us  cuDeviceGet
                    0.00%  1.9670us         1  1.9670us  1.9670us  1.9670us  cudaGetLastError
                    0.00%     361ns         1     361ns     361ns     361ns  cudaGetErrorString
$ CUDA_VISIBLE_DEVICES="1" /usr/local/cuda/samples/bin/x86_64/linux/release/bandwidthTest
[CUDA Bandwidth Test] - Starting...
Running on...

 Device 0: Tesla K20Xm
 Quick Mode

 Host to Device Bandwidth, 1 Device(s)
 PINNED Memory Transfers
   Transfer Size (Bytes)        Bandwidth(MB/s)
   33554432                     6375.8

 Device to Host Bandwidth, 1 Device(s)
 PINNED Memory Transfers
   Transfer Size (Bytes)        Bandwidth(MB/s)
   33554432                     6554.3

 Device to Device Bandwidth, 1 Device(s)
 PINNED Memory Transfers
   Transfer Size (Bytes)        Bandwidth(MB/s)
   33554432                     171220.3

Result = PASS

NOTE: The CUDA Samples are not meant for performance measurements. Results may vary when GPU Boost is enabled.
$

Ядру требуется 9,8240us для выполнения, и в это время загружается или сохраняется общее количество40960 * 3 * 4 * 3 байта данных.Поэтому достигнутая ядром пропускная способность памяти составляет 40960 * 3 * 4 * 3 / 0,000009824 или 150 ГБ / с.Измерение прокси для пика, достижимого на этом графическом процессоре, составляет 171 ГБ / с, поэтому это ядро ​​достигает 88% от оптимальной пропускной способности.Благодаря более тщательному бенчмаркингу для запуска ядра два раза подряд, для 2-го выполнения требуется всего 8,99us.Это обеспечивает достижимую полосу пропускания в этом случае до 96% максимальной достижимой пропускной способности.

...