Основываясь на своем предыдущем посте об алгоритмах сортировки, я реализовал те же алгоритмы с использованием CUDA для исследования улучшений производительности посредством параллельных вычислений. Цель состоит в том, чтобы увидеть, как мы можем использовать мощь параллельных вычислений для ускорения наших алгоритмов сортировки. Несколько дней назад я пошел на мероприятие по набору сотрудников NVIDIA, это было отличное мероприятие, и оно мотивировало меня попробовать переписать алгоритмы сортировки с использованием CUDA.
В качестве тестового алгоритма я выберу сортировку слиянием, поскольку она удачно разделяет задачу на меньшие подзадачи с двумя равными половинами, что хорошо подходит для параллельных вычислений.
Базовая рекурсивная сортировка слиянием сверху вниз
Ниже представлена базовая логика сортировки слиянием сверху вниз, где я рекурсивно делю массив на две половины, пока не достигну базового случая одного элемента, а затем снова объединяю отсортированные половины.
Чтобы объединить два отсортированных массива, мы сравниваем их начальные элементы, выбираем меньший из них для выходного массива и перемещаем указатели вперед.
MERGE_SORT(arr, left, right) IF left < right THEN mid <- left + (right - left) / 2 // Recursively sort first half MERGE_SORT(arr, left, mid) // Recursively sort second half MERGE_SORT(arr, mid + 1, right) // Merge the sorted halves MERGE(arr, left, mid, right) ENDIF END MERGE_SORT
Теперь давайте рассмотрим реализацию ЦП, ссылка на которую приведена ниже:
void merge(uint8_t* arr, uint8_t* temp, long long left, long long mid, long long right):
uint8_tвместо этого intдля элементов массива делается для сохранения небольших значений (0-255)
long longдля индексов допускается очень большой массив (10^18)
uint8_t* tempдействует как рабочее пространство для операции слияния и обеспечивает повышение производительности
void mergeSort(uint8_t* arr, uint8_t* temp, long long left, long long right)следует псевдокод, который делит массив на две половины и вызывает себя на этих двух половинах. Когда он достигает базового случая одного элемента, он вызывает функцию merge, чтобы снова объединить две половины вместе.
Сортировка GPU и CPU:
Массивы генерируются с переданными аргументами с определенным начальным числом (например, 1)
Все реализации выполняют примерно одинаковый объем работы.
Результат сортировки слиянием необходимо вызвать обратно в исходный массив из графического процессора в центральный процессор, что является накладными расходами, и сравнить с отсортированным массивом с использованием std::sortцентрального процессора.
Лучшим сравнением может быть сортировка случайных массивов на самом GPU и сравнение результатов.
То, как и где мы сортируем, имеет большое значение в зависимости от более широкого контекста использования.
Wall Clock timeдля построения графиков используется время выполнения всей программы, а не только время, затраченное на сортировку массива
Correctness checkingвыполняется путем сортировки исходного массива с помощью std::sortи сравнения результатов
Временная сложность в среднем случае: O(n log n)
Сложность пространства: O(n)
Базовая рекурсивная сортировка слиянием сверху вниз в CUDA
Теперь давайте посмотрим, как мы можем реализовать это в CUDA. Это следует той же схеме, что и реализация на CPU. Это моя первая наивная реализация на CUDA. Ядро запускается для каждой операции слияния, а рекурсия выполняется на CPU.
#include <cuda_runtime.h>обеспечивает доступ к API среды выполнения CUDA и таким функциям, как cudaMalloc(), cudaMemcpy(), cudaFree(), kernel<<<numBlocks, threadsPerBlock>>>(args),cudaGetErrorString()cudaGetLastError()
__global__ void mergeSort(uint8_t* arr, uint8_t* temp, long long left, long long right)это функция ядра, которая запускается для каждой операции слияния, которая на данный момент делает то же самое, что и реализация CPU
В пределахvoid mergeSort(....)
merge<<<1, 1>>>(...)запускает ядро для каждой операции слияния, но сейчас просто запускает один поток для выполнения всего слияния, что неэффективно. <<<1,1>>>указывает количество блоков потоков и количество потоков на блок потоков. <<<numBlocks, blockSize>>>— это синтаксис для запуска ядра в CUDA. Общее количество потоков, которые у вас есть, равно , numBlocks * blockSizeи они могут быть организованы в одномерной, двухмерной или трехмерной сетке.
cudaDeviceSynchronize()заставляет его ждать завершения этого слияния, прежде чем переходить к следующему этапу, чтобы избежать проблем с корректностью.
cudaMalloc(....)используется для выделения памяти на графическом процессоре cudaMemcpy(..., cudaMemcpyHostToDevice)и cudaMemcpy(...., cudaMemcpyDeviceToHost)может использоваться для копирования данных между центральным процессором и графическим процессором.
cudaFree(cu_arr)используется для освобождения памяти на графическом процессоре.
Сравнение реализации базовой рекурсивной сортировки слиянием на CPU и GPU
Эта реализация не очень эффективна, как вы можете видеть на рисунке 1, ядро запускается для каждой операции слияния, а рекурсия выполняется на CPU. CUDA не обрабатывает рекурсию эффективно, поэтому нам нужно свести рекурсию в цикл.
У меня возникли важные вопросы:
Почему CUDA плохо справляется с рекурсией?
Наша функция слияния запускается как один поток на GPU, а рекурсия выполняется на CPU. Глубокая рекурсия проблематична, так как может привести к переполнению стека, учитывая небольшой размер потоков на GPU. Для каждой операции слияния существуют нетривиальные накладные расходы на запуск ядра. Рекурсия не допускает большого параллелизма. Синхронизация также является проблемой.
Как мы можем улучшить ситуацию?
Перепишите рекурсию в итеративный цикл и выполните сортировку слиянием снизу вверх.
В отличие от реализаций на базе ЦП, где рекурсия широко используется, CUDA требует итерационного подхода с тщательным управлением памятью и синхронизацией потоков для достижения оптимальной производительности, как показано в реализациях ниже.
Итеративная сортировка слиянием снизу вверх
Поскольку CUDA неэффективно обрабатывает рекурсию из-за ограничений стека, вместо этого мы реализуем итеративный подход для сортировки слиянием. Суть итеративного подхода заключается в слиянии массива снизу вверх. Мы начинаем с слияния наименьших подмассивов размером 1, затем слияния подмассивов размером 2, затем 4, 8, 16 и т. д.
MERGE_SORT(arr, temp, start, end) FOR sub_size <- 1 TO end STEP 2 x sub_size DO FOR left <- 0 TO end STEP 2 x sub_size DO mid <- MIN(left + sub_size - 1, end) right <- MIN(left + 2 x sub_size - 1, end) MERGE(arr, temp, left, mid, right) ENDFOR ENDFOR END MERGE_SORT
Теперь давайте рассмотрим реализацию ЦП, ссылка на которую приведена ниже:
voidmergeSort(uint8_t* arr, uint8_t* temp, longlong n) { longlong left, mid, right, size; for (size = 1; size < n; size *= 2) { for (left = 0; left < n - size; left += 2 * size) { mid = left + size - 1; right = std::min(left + 2 * size - 1, n - 1); mergeKernel(arr, temp, left, mid, right); } } }
Мы превратили рекурсию в цикл:
Top for loopувеличивает размер из 1 to nв powers of 2так что у нас есть размеры 1,2,4,8. Можно было бы беспокоиться о том, что массивы, которые не очень хорошо подходят как степени 2, это тоже было для меня беспокойством, и это прекрасно обрабатывается clamping the right index to the end of the array.
Inner for loopпроходит по массиву с шагом 2*размер и объединяет подмассивы размером, sizeначиная с leftдо rightи midявляется серединой подмассива. Обратите внимание, right = std::min(left + 2 * size - 1, n - 1);что прикрепляет правый индекс к концу массива.
mergeKernelто же самое, что и mergeфункция в рекурсивном подходе, но теперь она вызывается в цикле.
Итеративная сортировка слиянием снизу вверх в CUDA
Лично для меня главный вывод из этой реализации. В вышеприведенной реализации есть два цикла, поэтому я подумал сделать второй цикл параллельно на GPU, который в основном выполняет операции слияния параллельно для всего массива.
flipflopиспользуется для отслеживания того, какой массив является окончательно отсортированным массивом, а какой — рабочим пространством.
numThreads— это количество потоков, которые нам необходимо запустить для операции слияния. gridSize— это количество блоков, которые нам необходимо запустить.
После вычисления размера объединенных массивов:
Учитывая размер массивов слияния, слияние происходит на двух подмассивах размера size. Поэтому мне нужно запустить n / (2 * size)потоки (что 1 помогает в случае, когда размер становится больше n/2).
gridSizeрассчитывается путем деления числа потоков на THREADS_PER_BLOCKи округления вверх. Размер сетки — это количество блоков, которые нам нужно запустить.
В mergeKernel мы указываем gridSize и THREADS_PER_BLOCK для запуска ядра. mergeKernel<<<gridSize, THREADS_PER_BLOCK>>>(flipflop ? arr : temp, flipflop ? temp : arr, size, n);Обратите внимание на тернарный оператор для переключения между массивами, в качестве которых выступают arr и temp, где ping-pong buffersв зависимости от состояния триггера мы считываем данные из одного и записываем данные в другой.
CUDA_CHECK(cudaGetLastError());и CUDA_CHECK(cudaDeviceSynchronize());используются для проверки ошибок и для того, чтобы убедиться, что ядро завершило выполнение перед переходом к следующему этапу.
if (!flipflop) CUDA_CHECK(cudaMemcpy(arr, temp, n * sizeof(uint8_t), cudaMemcpyDeviceToDevice));используется для копирования окончательно отсортированного массива обратно в исходный массив, если окончательно отсортированный массив находится во временном массиве.
Теперь давайте посмотрим на mergeKernel, который показан:
__global__ voidmergeKernel(uint8_t* arr, uint8_t* temp, longlong curr_size, longlong n) { longlong index = blockIdx.x * blockDim.x + threadIdx.x; longlong left = 2 * curr_size * index; if (left >= n) return; longlong mid = min(left + curr_size - 1, n - 1); longlong right = min(left + 2 * curr_size - 1, n - 1); longlong i = left, j = mid + 1, k = left; ///.... below is the good old merge logic }
Примечания:
Мне потребовалась уйма времени, чтобы понять индексацию и как правильно запустить ядро для операции слияния. Я запускаю 1d grids и 1d blocks. blockIdx.xдает индекс блока, который может быть 1,2,3,4,…, а затем blockDim.xуказывает количество потоков в блоке. blockIdx.x * blockDim.xпосле того, как мы добавляем его threadIdx.x(0 к THREADS_PER_BLOCK-1), дает индекс потока глобально, где каждый индекс уникален.
Теперь мы можем однозначно идентифицировать наш поток глобально, мы хотим дать ему в unique subproblemзависимости от этого index. Теперь мы переключаем наше внимание на вычисление индексов left, mid, и rightдля подмассива, который мы хотим объединить. У нас есть массив размером n, каждый поток должен иметь дело с подзадачами размером 2 * curr_sizeот leftдо right.
Most Important question: сколько у меня индексов? index= blockIdx.x * blockDim.x + threadIdx.xи если blockIdx.x равен 0 и threadIdx.x равен 0, то минимальный индекс равен 0. Мы знаем, что max blockIdx.x равен gridSize-1. Поэтому максимальный индекс создается (gridSize-1) * blockDim.x + blockDim.x - 1как gridSize * blockDim.x -1max. Если мы заменим gridSize на numThreads + THREADS_PER_BLOCK -1 / THREADS_PER_BLOCKи blockDim.x на THREADS_PER_BLOCK, то получим numThreads + THREADS_PER_BLOCK - 2. Таким образом, максимальный индекс приблизительно n / 2 x curr_sizeсоответствует нашим подзадачам с некоторыми дополнительными потоками.
Учитывая, что у нас есть индексы от 0 до n/2 * curr_size, мы можем вычислить left indexas 2 * curr_size * index, который примерно покрывает весь массив. Если у нас есть, left >= nмы возвращаемся, поскольку мы покрыли весь массив. Был интересный пограничный случай, когда этот left ранее был intведущим к переполнению, и мне пришлось изменить его на long long, я обнаружил эту ошибку с помощью compute-sanitizerи с помощью отладочных символов с помощью -g -Gпри компиляции с nvcc.
После того, как мы находим left, mid, right, применяется та же старая логика слияния.
Я пробовал разные значения, THREADS_PER_BLOCKно результаты были почти такими же.
Результаты
Мы определили задачу генерации случайных массивов на CPU, выполнения сортировки на CPU/GPU, а затем сравнения со стандартным методом сортировки std::sortна CPU.
Итеративная сортировка слиянием снизу вверх в CUDA значительно повышает эффективность за счет распараллеливания операций слияния
Неудивительно, что CPU approachesдля меньших массивов общий показатель времени выполнения программы улучшается.
thrust::sortоказывается лучше, чем мои реализации для больших массивов, где GPU iterative methodрекурсивный reasonably competitiveподход сильно отстает.
CPUподходы, такие как рекурсивный и итеративный, весьма конкурентоспособны сstd::sort
10^7 — это переломный момент, когда сортировка на GPU thrust::sortпревосходит стандартную сортировку на CPU, а моя реализация GPU iterativeтакже очень близка к этому.
Накладные расходы на отправку данных на графический процессор и получение данных обратно с графического процессора, вероятно, являются причиной разницы во времени между подходами CPU и GPU для размеров от 10^1 до 10^4.
Заключение и будущая работа
Я узнал много нового о сортировке слиянием, которая долгое время меня не касалась. Этот простой алгоритм также дал прекрасную возможность изучить основы CUDA на среднем уровне сложности. Есть несколько вещей, которые я мог бы сделать лучше и хотел бы изучить в будущем:
Определите задачи лучше или больше задач, где цели могли бы быть:
Все должно начинаться с CPU и заканчиваться на GPU только для дальнейших вычислений или наоборот.
Все должно начинаться и заканчиваться только на отдельных устройствах.
Все должно начинаться на GPU и переходить на CPU для дальнейших вычислений.
Попытка реализации Parallel Merge Sort, как предложено Prof Rezaulв SBUссылках [1]
Сравните, насколько большие массивы поступают с устройства, 10^7 to 10^18и stress testingсколько сортировок мы можем выполнить на каждом из них.
Оптимизируйте производительность на GPU еще больше shared memory, используя thrust:sortat specific levelв сочетании с моей реализацией, как мы делаем длинное умножение в алгоритме Карацубы для размера n < 20, как меня CSE 201научил Prof Sesh.
Используйте потоки в реализациях ЦП, чтобы увидеть, может ли это быть полезным для повышения производительности.
Сравните эффекты использования различных размеров THREAD_PER_BLOCKи, возможно, вместо того, чтобы каждый поток решал одну подзадачу, each threads solve more than 1 subproblemsучитывая, что мы ждем завершения всех потоков.