- PVSM.RU - https://www.pvsm.ru -

Многопоточная сказка о потерянном времени

В публикации «Сказка о потерянном времени» [1] пользователь crea7or [2] рассказал, как он опровергал Гипотезу Эйлера [3] на современном CPU.

Мне же было интересно узнать как покажет себя GPU, и я сравнил однопоточный код с многопоточным для CPU и совсем многопоточным для GPU, с помощью архитектуры параллельных вычислений CUDA [4].

Железо и компиляторы

CPU Core i5 4440 [5]
GPU GeForce GTX 770 [6]
MSVS 2015 update 3 [7]
tookit CUDA 8 [4].
Конечно, сборка была релизная и 64 битная, с оптимизацией /02 и /LTCG.

Отключение сброса видеоадаптера

Так как вычисления длятся более двух секунд, видеодрайвер завершает CUDA-программу. Чтобы этого не происходило, нужно указать достаточное время до сброса через ключ реестра
HKEY_LOCAL_MACHINESYSTEMCurrentControlSetControlGraphicsDriversTdrDelay и перезагрузить компьютер.

CPU

Для начала я распараллелил алгоритм [8] для CPU. Для этого в функцию передается диапазон для перебора значений внешнего цикла a. Затем весь диапазон 1..N разбивается на кусочки и скармливается ядрам процессора.

void Algorithm_1(const uint32_t N, const std::vector<uint64_t>& powers, const uint32_t from, const uint32_t to) {
  for (uint32_t a = from; a < to; ++a) {
    for (uint32_t b = 1; b < a; ++b) {
      for (uint32_t c = 1; c < b; ++c) {
        for (uint32_t d = 1; d < c; ++d) {
          const uint64_t sum = powers[a] + powers[b] + powers[c] + powers[d];
          if (std::binary_search(std::begin(powers), std::end(powers), sum)) {
            const auto it = std::lower_bound(std::begin(powers), std::end(powers), sum);
            uint32_t e = static_cast<uint32_t>(std::distance(std::begin(powers), it));
            std::cout << a << " " << b << " " << c << " " << d << " " << e << "n";
          }
        }
      }
    }
  }
}

В моем случае было 4 ядра, и вычисления я выполнял через чуть-чуть доработанный пул потоков, который взял за основу тут [9]. Так как никаких зависимостей между данными нет, скорость возрастает практически пропорционально количеству ядер.

GPU

Для GPU получается немного сложнее. Внешние два цикла перебора (a и b) будут развернуты, а в функции останется только перебор двух внутренних циклов (c и d). Можно себе представить что программа будет выполняться параллельно для всех значений а = 1..N и b = 1..N. На самом деле конечно это не так, все исполняться параллельно они все-таки не смогут, но это уже забота CUDA максимально распараллелить выполнение, этому можно помочь, если правильно настроить конфигурацию блоков и потоков.

Блоки и потоки

  int blocks_x = N;
  int threads = std::min(1024, static_cast<int>(N));
  int blocks_y = (N + threads - 1) / threads;
  dim3 grid(blocks_x, blocks_y);
  NaiveGPUKernel<<<grid, threads>>>(N);

blocks_x — это диапазон перебора первого цикла a.
А вот перебор второго цикла b пришлось сделать сложнее из-за ограничения видеокарты на количество потоков (1024), и поместить счетчик одновременно в threads и blocks_y:
a и b вычисляется так:

  const int a = blockIdx.x + 1;
  const int b = threadIdx.x + blockIdx.y * blockDim.x + 1;

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

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

__constant__ uint64_t gpu_powers[8192];

inline __device__ int gpu_binary_search(const uint32_t N, const uint64_t search) {
  uint32_t l = 0;
  uint32_t r = elements_count - 1;
  uint32_t m;
  while (l <= r) {
    m = (l + r) / 2;
    if (search < gpu_powers[m])
      r = m - 1;
    else if (search > gpu_powers[m])
      l = m + 1;
    else
     return l;
  }
  return -1;
}

__global__ void NaiveGPUKernel(const uint32_t N) {
  const int a = blockIdx.x + 1;
  const int b = threadIdx.x + blockIdx.y * blockDim.x + 1;
  if (b >= a)
    return;
  for (uint32_t c = 1; c < b; ++c) {
    for (uint32_t d = 1; d < c; ++d) {
      const uint64_t sum = gpu_powers[a] + gpu_powers[b] + gpu_powers[c] + gpu_powers[d];
      const auto s = gpu_binary_search(N, sum);
      if (s > 0) {
        printf("%d %d %d %d %dn", a, b, c, d, s);
      }
    }
  }
}

Так же я реализовал оптимизации, подсказанные тут [8].

Замеры скорости

Замеры для CPU делались по два раза, так как они оказались намного стабильнее GPU, которых я делал уже по семь и выбирал лучшее время. Разброс для GPU мог быть двукратным, это я объясняю тем что для CUDA-расчетов использовался единственный в системе видеоадаптер.

N CPU время, мс CPU (4 потока) время, мс GPU время, мс
@antonkrechetov [10] 100 58.6 16.7 14.8
Базовый [8] 100 45.3 13.0 10.7
Базовый оптимизация #1 [8] 100 6.3 2.1 12.7
Базовый оптимизация #2 [8] 100 1.4 0.7 0.8
@antonkrechetov [10] 250 2999.7 782.9 119.0
Базовый [8] 250 2055.6 550.1 90.9
Базовый оптимизация #1 [8] 250 227.2 60.3 109.2
Базовый оптимизация #2 [8] 250 42.9 11.9 6.0
@antonkrechetov [10] 500 72034.2 19344.1 1725.83
Базовый [8] 500 38219.7 10200.8 976.7
Базовый оптимизация #1 [8] 500 3725.1 926.5 1140.36
Базовый оптимизация #2 [8] 500 630.7 170.2 48.7
@antonkrechetov [10] 750 396566.0 105003.0 11521.2
Базовый [8] 750 218615.0 57639.2 5742.5
Базовый оптимизация #1 [8] 750 19082.7 4736.8 6402.1
Базовый оптимизация #2 [8] 750 3272.0 846.9 222.9
Базовый оптимизация #2 [8] 1000 10204.4 2730.3 1041.9
Базовый оптимизация #2 [8] 1250 25133.1 6515.3 2445.5
Базовый оптимизация #2 [8] 1500 51940.1 14005.0 4895.2

А теперь вколем немного адреналина в CPU!

И этим адреналином будет Profile-guided optimization [11].

Для PGO я привожу только время CPU, потому что GPU мало изменилось, и это ожидаемо.

N CPU
время, мс
CPU (4 потока)
время, мс
CPU+PGO
время, мс
CPU+PGO
(4 потока)
время, мс
@antonkrechetov [10] 100 58.6 16.7 55.3 15.0
Базовый [8] 100 45.3 13.0 42.2 12.1
Базовый оптимизация #1 [8] 100 6.3 2.1 5.2 1.9
Базовый оптимизация #2 [8] 100 1.4 0.7 1.3 0.8
@antonkrechetov [10] 250 2999.7 782.9 2966.1 774.1
Базовый [8] 250 2055.6 550.1 2050.2 544.6
Базовый оптимизация #1 [8] 250 227.2 60.3 200.0 53.2
Базовый оптимизация #2 [8] 250 42.9 11.9 40.4 11.4
@antonkrechetov [10] 500 72034.2 19344.1 68662.8 17959.0
Базовый [8] 500 38219.7 10200.8 38077.7 10034.0
Базовый оптимизация #1 [8] 500 3725.1 926.5 3132.9 822.2
Базовый оптимизация #2 [8] 500 630.7 170.2 618.1 160.6
@antonkrechetov [10] 750 396566.0 105003.0 404692.0 103602.0
Базовый [8] 750 218615.0 57639.2 207975.0 54868.2
Базовый оптимизация #1 [8] 750 19082.7 4736.8 15496.4 4082.3
Базовый оптимизация #2 [8] 750 3272.0 846.9 3093.8 812.7
Базовый оптимизация #2 [8] 1000 10204.4 2730.3 9781.6 2565.9
Базовый оптимизация #2 [8] 1250 25133.1 6515.3 23704.3 6244.1
Базовый оптимизация #2 [8] 1500 51940.1 14005.0 48717.5 12793.5

Видно что PGO смогло ускорить оптимизацию #1 на целых 18%, для остальных прирост оказался скромнее.

Что можно сделать еще

Для CPU попробовать развернуть циклы, считать по два числа за раз, использовать векторные команды процессора.

Для GPU поиграть с порядком вычислений, попробовать по-экономить на регистрах, лучше подобрать параметры блоков и потоков.

Выводы

1. Писать на CUDA просто.
2. Без ухищрений, прямолинейный код на CUDA оказался быстрее CPU реализации в десятки раз.
3. В числодробильных задачах GPU сильно быстрее CPU за те же деньги.
4. GPU требуется много времени на «раскачку» и для совсем коротких задач ускорения может не быть.
5. Более сложный алгоритм может оказаться быстрее для CPU, но медленнее для GPU.
6. Профильная оптимизация хорошо ускоряет код и уменьшает размер файла одновременно.

Исходники

Традиционно можно потрогать код на GitHub [12]

Автор: drBasic

Источник [13]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/algoritmy/223828

Ссылки в тексте:

[1] «Сказка о потерянном времени»: https://habrahabr.ru/post/317588/

[2] crea7or: https://habrahabr.ru/users/crea7or/

[3] Гипотезу Эйлера: https://ru.wikipedia.org/wiki/Гипотеза_Эйлера

[4] CUDA: http://www.nvidia.ru/object/cuda-parallel-computing-ru.html

[5] Core i5 4440: http://ark.intel.com/products/75038/Intel-Core-i5-4440-Processor-6M-Cache-up-to-3_30-GHz

[6] GeForce GTX 770: http://www.nvidia.ru/object/geforce-gtx-770-ru

[7] MSVS 2015 update 3: https://www.microsoft.com/ru-ru/download/details.aspx?id=48146

[8] алгоритм: http://rosettacode.org/wiki/Euler%27s_sum_of_powers_conjecture#C.2B.2B

[9] тут: https://github.com/progschj/ThreadPool/blob/master/ThreadPool.h

[10] @antonkrechetov: https://habrahabr.ru/post/317588/#comment_9965062

[11] Profile-guided optimization: https://ru.wikipedia.org/wiki/Profile-guided_optimization

[12] GitHub: https://github.com/drbasic/EulerSum

[13] Источник: https://habrahabr.ru/post/318066/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best