- PVSM.RU - https://www.pvsm.ru -
Я решил изучить, как повысится производительность алгоритмов сортировки при их реализации на CUDA. Моя цель — понять, как можно использовать мощь параллельных вычислений для ускорения алгоритмов сортировки.
В качестве тестового я возьму алгоритм сортировки слиянием (merge sort), потому что он удобно разбивает задачу на меньшие подзадачи с двумя равными половинами, что хорошо подходит для параллельных вычислений.
Ниже показана базовая логика сортировки слиянием сверху вниз, в которой я рекурсивно делю массив на две половины, пока не достигну базового случая из одного элемента, а затем выполняю слияние отсортированных половин.
Для слияния двух отсортированных массивов мы вычисляем их начальные элементы, выбираем меньший для выходного массива и двигаем указатели вперёд.
MERGE_SORT(arr, left, right)
IF left < right THEN
mid ← left + (right - left) / 2
// Рекурсивно сортируем первую половину
MERGE_SORT(arr, left, mid)
// Рекурсивно сортируем вторую половину
MERGE_SORT(arr, mid + 1, right)
// Сливаем отсортированные половины
MERGE(arr, left, mid, right)
ENDIF
END MERGE_SORT
А теперь давайте взглянем на реализацию на CPU:
Code: Basic Recursive Merge Sort on CPU [1]
void merge(uint8_t* arr, uint8_t* temp, long long left, long long mid, long long right)
:
uint8_t
вместо int
для массивов элементов взят, чтобы значения оставались маленькими (0-255).long long
для индексов позволяет использовать очень большие массивы (1018).uint8_t* temp
применяется как рабочее пространство для операции слияния, обеспечивая повышение производительности.void mergeSort(uint8_t* arr, uint8_t* temp, long long left, long long right)
соответствует псевдокоду, который разбивает массив на две половины и вызывает сам себя для этих двух половин. Когда он достигает базового случая (одного элемента), он вызывает функцию слияния для объединения двух половин.std::sort
на CPU.физическое время
выполнения всей программы, а не только время, потраченное на сортировку массива.Проверка корректности
выполняется сортировкой исходного массива при помощи std::sort
и сравнением результатов.Теперь давайте посмотрим, как можно реализовать этот алгоритм на CUDA. Он следует тому же паттерну, что и реализация на CPU. Это моя первая наивная реализация на CUDA. Ядро запускается для каждой операции слияния, а рекурсия выполняется на CPU.
Code: Basic Recursive Merge Sort with CUDA [2]
#include <cuda_runtime.h>
предоставляет доступ к CUDA Runtime API и функциям типа 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
, и их можно выстраивать в 1D-, 2D- или 3D-сетку.cudaDeviceSynchronize()
заставляет ожидать завершения этого слияния, прежде чем переходить к следующему этапу, чтобы избежать проблем с корректностью.cudaMalloc(....)
используется для распределения памяти в GPU. cudaMemcpy(..., cudaMemcpyHostToDevice)
и cudaMemcpy(...., cudaMemcpyDeviceToHost)
можно использовать для копирования данных между CPU и GPU.cudaFree(cu_arr)
используется для освобождения памяти в GPU.Как видно на Рисунке 1, эта реализация не особо эффективна: ядро запускается для каждой операции слияния, а рекурсия выполняется на CPU. CUDA не обрабатывает рекурсию эффективно, поэтому мы должны преобразовать рекурсию в цикл.
У меня возникли важные вопросы:
В отличие от реализаций на CPU, в которых широко применяется рекурсия, для обеспечения оптимальной производительности CUDA требует итеративного подхода с аккуратным управлением памятью и синхронизацией потоков; это видно из показанной ниже реализации.
Так как CUDA не может эффективно выполнять рекурсию из-за ограничений стека, мы реализуем для сортировки слиянием итеративный подход. Основой итеративного подхода будет слияние массива снизу вверх. Мы начинаем со слияния наименьших подмассивов размера 1, затем сливаем подмассивы размера 2, затем 4, 8, 16 и так далее.
MERGE_SORT(arr, temp, start, end)
FOR sub_size ← 1 TO end STEP 2 × sub_size DO
FOR left ← 0 TO end STEP 2 × sub_size DO
mid ← MIN(left + sub_size - 1, end)
right ← MIN(left + 2 × sub_size - 1, end)
MERGE(arr, temp, left, mid, right)
ENDFOR
ENDFOR
END MERGE_SORT
А теперь давайте посмотрим реализацию на CPU:
Code: Iterative Merge Sort on CPU [3]
void mergeSort(uint8_t* arr, uint8_t* temp, long long n) {
long long 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);
}
}
}
Мы превратили рекурсию в цикл:
Верхний цикл for
увеличивает размер с 1 до n
по степеням двойки
, так что мы получаем размеры 1,2,4,8
. Могут возникнуть опасения, что размеры массивов неточно совпадут со степенями двойки; я решил эту проблему, ограничив индекс right концом массива
.Внутренний цикл for
проходит по массиву шагом в 2*size и выполняет слияние подмассивов размера size
, начиная с left
до right
, а mid
— это середина подмассива. Обратите внимание, что right = std::min(left + 2 * size - 1, n - 1);
, что ограничивает правый индекс концом массива.mergeKernel
эквивалентна функции merge
в решении с рекурсией, но теперь она вызывается в цикле.Лично для меня самым важным уроком стала эта реализация. В приведённой выше реализации присутствуют два цикла, поэтому я решил, что можно выполнять второй цикл параллельно на GPU, ведь он в основном параллельно производит операции слияния для всего массива.
void mergeSort(uint8_t* arr, uint8_t* temp, long long n) {
bool flipflop = true;
long long numThreads, gridSize;
long long size; // size - это размеры массивов слияния
for (size = 1; size < n; size *= 2) {
numThreads = max(n / (2 * size), (long long)1);
gridSize = (numThreads + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
mergeKernel<<<gridSize, THREADS_PER_BLOCK>>>(flipflop ? arr : temp, flipflop ? temp : arr, size, n);
CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaDeviceSynchronize());
flipflop = !flipflop;
}
if (!flipflop) CUDA_CHECK(cudaMemcpy(arr, temp, n * sizeof(uint8_t), cudaMemcpyDeviceToDevice));
}
flipflop
используется для отслеживания того, какой массив является окончательным отсортированным, а какой рабочим пространством.numThreads
— это количество потоков, которое необходимо запустить для операции слияния. gridSize
— это количество блоков, которое нужно запустить.size
. Поэтому мне нужно запускать n / (2 * size)
потоков (1 помогает в случаях, когда размер становится больше n/2).gridSize
вычисляется делением количества потоков на THREADS_PER_BLOCK
и округлением вверх. Grid size — это количество блоков, которое нужно запустить.mergeKernel<<<gridSize, THREADS_PER_BLOCK>>>(flipflop ? arr : temp, flipflop ? temp : arr, size, n);
: обратите внимание на тернарный оператор для переключения между массивами, в котором arr и temp служат в качестве ping-pong buffer
— на основании состояния flipflop мы считываем из одного и записываем в другой.CUDA_CHECK(cudaGetLastError());
и CUDA_CHECK(cudaDeviceSynchronize());
используются для проверки на ошибки и чтобы ядро точно завершило исполнение до перехода на следующий этап.if (!flipflop) CUDA_CHECK(cudaMemcpy(arr, temp, n * sizeof(uint8_t), cudaMemcpyDeviceToDevice));
используется для копирования окончательного отсортированного массива обратно в исходный массив, если окончательный отсортированный массив находится в массиве temp.Теперь давайте взглянем на mergeKernel:
__global__ void mergeKernel(uint8_t* arr, uint8_t* temp, long long curr_size, long long n) {
long long index = blockIdx.x * blockDim.x + threadIdx.x;
long long left = 2 * curr_size * index;
if (left >= n) return;
long long mid = min(left + curr_size - 1, n - 1);
long long right = min(left + 2 * curr_size - 1, n - 1);
long long i = left, j = mid + 1, k = left;
///.... ниже идёт обычная логика слияния
}
blockIdx.x
даёт блоку индекс, который может иметь значение 1,2,3,4,… а затем blockDim.x
указывает количество потоков в блоке. blockIdx.x * blockDim.x
после прибавления threadIdx.x
(от 0 до THREADS_PER_BLOCK-1) даёт нам глобальный уникальный индекс потока.уникальную подзадачу
в зависимости от его index
. Перейдём к вычислению индексов left
, mid
и right
для подмассива, слияние которого хотим выполнить. У нас есть массив размера n, каждый поток должен работать с подзадачами размера 2 * curr_size
, начиная с left
до right
.Самый важный вопрос
: сколько индексов у меня получилось? index= blockIdx.x * blockDim.x + threadIdx.x
и если blockIdx.x равен 0 и threadIdx.x равен 0, то минимальный индекс будет равен 0. Мы знаем, что максимальный blockIdx.x равен gridSize-1. Так что максимальный индекс равен (gridSize-1) * blockDim.x + blockDim.x - 1
, то есть gridSize * blockDim.x -1
. Если заменить gridSize на numThreads + THREADS_PER_BLOCK -1 / THREADS_PER_BLOCK
, а blockDim.x на THREADS_PER_BLOCK
, то мы получим numThreads + THREADS_PER_BLOCK - 2
. То есть максимальный индекс для решения наших подзадач с несколькими дополнительными потоками приблизительно равен n / 2 × curr_size
.n/2 * curr_size
, можно вычислить left index
как 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.
решения на CPU
проявляют себя лучше на маленьких массивах при замере физического времени выполнения программы.thrust::sort
оказывается для больших массивов лучше, чем мои реализации: итеративный способ на GPU
вполне конкурентоспособен, а рекурсивный способ сильно отстаёт.CPU
, то есть рекурсивный и итеративный, очень конкурентоспособны по сравнению с std::sort
thrust::sort
начинает побеждать стандартную сортировку на CPU, а моя итеративная реализация на GPU
тоже к ней приближается.Я получил множество новых знаний о сортировке слиянием, которые долгое время ускользали от меня. Кроме того, этот простой алгоритм позволил мне изучить основы CUDA на среднем уровне сложности. Можно было многое сделать лучше, и это нужно будет исследовать в будущем:
параллельную сортировку слиянием
, предложенную Rezaul Chowdhury в источнике [1]107 до 1018
и выполнять нагрузочное тестирование
объёма сортировки, который мы можем выполнить на устройствах.общей памяти
, thrust:sort
на конкретном уровне
в сочетании с моей реализации.THREAD_PER_BLOCK
и, возможно, попробовать использовать каждый поток для решения не одной, а нескольких подзадач, поскольку мы ожидаем, пока закончат работу все потоки.Автор: ru_vds
Источник [16]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/algoritmy-sortirovki/414132
Ссылки в тексте:
[1] Code: Basic Recursive Merge Sort on CPU: https://gist.github.com/ashwanirathee/b2bd7b9ad81179b48863c4074ff0258a#file-merge_sort-cpp
[2] Code: Basic Recursive Merge Sort with CUDA: https://gist.github.com/ashwanirathee/b2bd7b9ad81179b48863c4074ff0258a#file-merge_sort-cu
[3] Code: Iterative Merge Sort on CPU: https://gist.github.com/ashwanirathee/b2bd7b9ad81179b48863c4074ff0258a#file-iterative_merge_sort-cpp
[4] Заметки Rezaul Chowdhury: https://www3.cs.stonybrook.edu/~rezaul/Spring-2019/CSE613/CSE613-lecture-7.pdf
[5] GeeksForGeeks: MergeSort: https://www.geeksforgeeks.org/merge-sort/
[6] NVIDIA CUDA Intro: https://developer.nvidia.com/blog/even-easier-introduction-cuda/
[7] MergeSort на YouTube: https://www.youtube.com/watch?v=_XOZ2IiP2nw
[8] Linebender GPU Sorting: https://linebender.org/wiki/gpu/sorting/
[9] Onesweep Paper: https://research.nvidia.com/publication/2022-06_onesweep-faster-least-significant-digit-radix-sort-gpus
[10] Onesweep GPU Code by Thomas Smith: https://github.com/b0nes164/GPUSorting
[11] NVIDIA OneSweep: https://github.com/NVIDIA/cccl/blob/main/cub/cub/agent/agent_radix_sort_onesweep.cuh
[12] Approachable Radix Sort Introduction: https://gpuopen.com/download/publications/Introduction_to_GPU_Radix_Sort.pdf
[13] Loop unrolling and thread coarsening: https://www.spenceruresk.com/loop-unrolling-gone-bad-e81f66f03ed1
[14] Bitonic Sort implementation: https://gist.github.com/mre/1392067
[15] Bitonic Sort 2: https://winwang.blog/posts/bitonic-sort/
[16] Источник: https://habr.com/ru/companies/ruvds/articles/892306/?utm_source=habrahabr&utm_medium=rss&utm_campaign=892306
Нажмите здесь для печати.