- PVSM.RU - https://www.pvsm.ru -
В данной статье хочу рассказать как можно эффективно распараллелить алгоритм SSSP — поиска кратчайшего пути в графе с использованием графических ускорителей. В качестве графического ускорителя будет рассмотрена карта GTX Titan архитектуры Kepler [1].
В последнее время все большую роль играют графические ускорители (GPU) в не графических вычислениях. Потребность их использования обусловлена их относительно высокой производительностью и более низкой стоимостью. Как известно, на GPU хорошо решаются задачи на структурных сетках, где параллелизм так или иначе легко выделяется. Но есть задачи, которые требуют больших мощностей и используют неструктурные сетки. Примером такой задачи является Single Shortest Source Path problem (SSSP) – задача поиска кратчайших путей от заданной вершины до всех остальных во взвешенном графе. Для решения данной задачи на CPU существует, по крайней мере, два известных алгоритма: алгоритм Дейсктры и алгоритм Форда-Беллмана. Так же существуют параллельные реализации алгоритма Дейстры и Форда-Беллмана на GPU. Вот основные статьи, в которых описаны решения данной задачи:
Есть и другие англоязычные статьи. Но во всех данных статьях используется один и тот же подход – идея алгоритма Дейсктры. Я опишу, как можно использовать идею алгоритма Форда-Беллмана и преимущества архитектуры Kepler для решения поставленной задачи. Об архитектуре GPU и об упомянутых алгоритмах уже много написано, поэтому в этой статье я не стану дополнительно писать про это. Так же, считается, что понятия варп(warp), cuda блок, SMX, и прочие базовые вещи, связанные с CUDA [2] читателю знакомы.
Кратко рассмотрим структуру хранения неориентированного взвешенного графа, так как в дальнейшем она будет упоминаться и преобразовываться. Граф задается в сжатом CSR формате. Данный формат широко распространен для хранения разреженных матриц и графов. Для графа с N вершинами и M ребрами необходимо три массива: xadj, adjncy, weights. Массив xadj размера N + 1, остальные два – 2*M, так как в неориентированном графе для любой пары вершин необходимо хранить прямую и обратную дуги.
Принцип хранения графа следующий. Весь список соседей вершины I находится в массиве adjncy с индекса xadj[I] до xadj[I+1], не включая его. По аналогичным индексам хранятся веса каждого ребра из вершины I. Для иллюстрации на рисунке слева показан граф из 5 вершин, записанный с помощью матрицы смежности, а справа – в CSR формате.
Для того чтобы увеличить вычислительную нагрузку на один потоковый мультипроцессор (SMX), необходимо преобразовать входные данные. Все преобразования можно разделить на два этапа:
На первом этапе необходимо расширить формат CSR следующим образом: введем еще один массива startV, в котором будем хранить начала дуг. Тогда в массиве adjncy будут храниться их концы. Таким образом, вместо хранения соседей, будем хранить дуги. Пример данного преобразования на графе, описанном выше:
На втором этапе необходимо отсортировать полученные ребра так, чтобы каждая пара (U,V) встречалась ровно один раз. Таким образом, при обработке ребра (U,V) можно сразу обработать ребро (V,U), не считывая повторно данные об этом ребре из глобальной памяти GPU.
За основу для реализации на GPU взят алгоритм Форда-Беллмана [3]. Данный алгоритм хорошо подходит для реализации на GPU в силу того, что ребра можно просматривать независимо друг от друга и данные ребер и их весов расположены подряд, что улучшает пропускную способность памяти GPU:
int k = blockIdx.x * blockDim.x + threadIdx.x;
if(k < maxV)
{
double w = weights[k];
unsigned en = endV[k];
unsigned st = startV[k];
if(dist[st] > dist[en] + w) // (*)
{
dist[st] = dist[en] + w;
modif[0] = iter;
}
else if(dist[en] > dist[st] + w) // (**)
{
dist[en] = dist[st] + w;
modif[0] = iter;
}
}
В данном ядре, каждая нить обрабатывает два ребра (прямое и обратное), пытаясь улучшить расстояние по одному из них. Ясно, что оба условия if блока не могут выполниться одновременно. В отличие от алгоритма Форда-Беллмана, где каждое ребро просматривается последовательно, в реализованном на GPU алгоритме может возникнуть ситуация «гонки» потоков – когда два или более потока заходят обновить одну и ту же ячейку dist[I]. Покажем, что в данном случае алгоритм останется корректным.
Предположим, что есть две нити K1 и K2, которые хотят обновить ячейку dist[I]. Это означает, что выполнилось условие (*) или (**). Возможны два случая. Первый – одна из двух нитей записала наименьшее значение. Тогда на следующей итерации для этих двух нитей условие будет ложно, а значение в ячейке dist[I] окажется минимальным. Второй – одна из двух нитей записала не минимальное значение. Тогда на следующей итерации для одной из нитей условие будет истинно, а для другой – ложно. Тем самым, результат будет одинаковый в обоих случаях, но достигнут за разное количество итераций.
Согласно оптимизированному варианту алгоритма Форда-Беллмана, если на какой-либо итерации не было изменений в массиве dist, то дальше итерировать не имеет смысла. На GPU для этих целей была введена переменная modif, в которую нити записывали номер текущей итерации.
Одна итерация – один запуск ядра. В базовом варианте, на CPU в цикле запускаем ядро, далее считываем переменную modif и, если она не изменилась с предыдущей итерации, то в массиве dist ответ задачи – кратчайший путь из заданной вершины до всех остальных.
Далее рассмотрим некоторые оптимизации, которые позволяют существенно улучшить производительность алгоритма. Знание конечной архитектуры может быть полезным при выполнении оптимизаций.
Современные CPU имеют трехуровневый кэш. Размер кэша первого уровня равен 64КБ и содержится на всех вычислительных ядрах процессора. Размер кэша второго уровня варьируется от 1 до 2МБ. Кэш третьего уровня является общим для всего CPU и имеет размер порядка 12-15МБ.
Современные GPU имеют двухуровневый кэш. Размер кэша первого уровня равен 64КБ. Используется для разделяемой памяти и вытеснения регистров. Для разделяемой памяти доступно не более 48КБ. Содержится в каждом вычислительном блоке. Максимальный размер кэша второго уровня составляет 1,5МБ и является общим для всего GPU. Используется для кэширования данных, загружаемых из глобальной памяти GPU. В самом современном чипе GPU GK110 имеется 15 вычислительных блоков. Получается, что на один блок приходится примерно 48КБ кэша первого уровня и 102КБ кэша второго уровня. По сравнению с CPU, это очень мало, поэтому операции чтения из глобальной памяти графического процессора дороже, чем из оперативной памяти центрального процессора. Также в архитектуре Kepler появилась возможность прямого доступа в текстурный read-only кэш. Для этого необходимо добавить в ядре перед соответствующим формальным параметром const __restrict.
В данной задаче требуется постоянно обновлять и считывать массив расстояний dist. Данный массив занимает достаточно мало места в глобальной памяти GPU по сравнению с информацией о дугах и их весах. Например, для графа с количеством вершин 220 (примерно 1 млн) массив dist будет занимать 8МБ. Несмотря на это, доступ к этому массиву осуществляется случайным образом, что плохо для GPU, так как порождаются дополнительные загрузки из глобальной памяти на каждый вычислительный варп. Для того чтобы минимизировать количество загрузок на один варп, необходимо сохранить данные в L2 кэше, прочитанные, но не использованные данные другими варпами.
Так как текстурный кэш является read-only, то для того чтобы им воспользоваться пришлось ввести две разные ссылки на один и тот же массив расстояний dist. Соответствующий код:
__global__ void relax_ker (const double * __restrict dist, double *dist1, … …)
{
int k = blockIdx.x * blockDim.x + threadIdx.x + minV;
if(k < maxV)
{
double w = weights[k];
unsigned en = endV[k];
unsigned st = startV[k];
if(dist[st] > dist[en] + w)
{
dist1[st] = dist[en] + w;
modif[0] = iter;
}
else if(dist[en] > dist[st] + w)
{
dist1[en] = dist[st] + w;
modif[0] = iter;
}
}
}
В итоге получилось так, что внутри ядра все операции чтения ведутся с одним массивом, а все операции записи с другим. Но обе ссылки dist и dist1 указывают на один и тот же участок памяти GPU.
Для лучшей работы описанной выше оптимизации необходимо, чтобы как можно дольше загруженные данные находились в L2 кэше. Доступ к массиву dist осуществляется по заранее известным индексам, хранящимся в массивах endV и startV. Для локализации обращений поделим массив dist на сегменты определенной длины, например, P элементов. Так как всего в графе N вершин, то получим (N / P + 1) разных сегментов. Далее, будем пересортировывать ребра в данные сегменты следующим образом. В первую группу отнесем ребра, концы которых попадают в нулевой сегмент, а начала – сначала в нулевой, затем в первый и т.д. Во вторую группу отнесем ребра, концы которых попадают в первый сегмент, а начала – сначала в нулевой, затем в первый и т.д.
После данной перестановки ребер, значения элементов массива dist, соответствующие, например, первой группе будут находиться в кэше максимально долго, так как нити в первой группе будут запрашивать данные из нулевого сегмента для концевых вершин и нулевого, первого и т.д. для начальных вершин. Причем ребра расположены так, что нити будут запрашивать данные не более чем из трех различных сегментов.
Для тестирования использовались синтетические неориентированные RMAT-графы [4], которые хорошо моделируют реальные графы из социальных сетей и Интернета. Графы имеют среднюю связность 32, количество вершин является степень двойки. В таблице ниже приведены графы, на которых проводилось тестирование.
Количество вершин 2^N | Количество вершин | Количество дуг | Размер массива dist в МБ | Размер массивов ребер и весов в МБ |
14 | 16 384 | 524 288 | 0,125 | 3 |
15 | 32 768 | 1 048 576 | 0,250 | 6 |
16 | 65 536 | 2 097 152 | 0,500 | 12 |
17 | 131 072 | 4 194 304 | 1 | 32 |
18 | 262 144 | 8 388 608 | 2 | 64 |
19 | 524 288 | 16 777 216 | 4 | 128 |
20 | 1 048 576 | 33 554 432 | 8 | 256 |
21 | 2 097 152 | 67 108 864 | 16 | 512 |
22 | 4 194 304 | 134 217 728 | 32 | 1024 |
23 | 8 388 608 | 268 435 456 | 64 | 2048 |
24 | 16 777 216 | 536 870 912 | 128 | 4096 |
Из таблицы видно, что массив расстояний dist для графа с количеством вершин 218 и более не помещается целиком в L2 кэш на GPU. Тестирование производилось на GPU Nividia GTX Titan, у которого 14 SMX с 192 cuda ядрами (всего 2688) и на процессоре Intel core i7 3го поколения с частотой 3,4ГГц и 8МБ кэша. Для сравнения производительности на CPU использовался оптимизированный алгоритм Дейкстры. Никаких оптимизаций в виде перестановки данных перед работой на CPU не производились. Вместо времени показателем производительности будем считать количество дуг, обработанных за секунду времени. В данном случае, необходимо поделить полученное время на количество дуг в графе. В качестве конечного результата бралось среднее значение по 32 точкам. Также вычислялось максимальное и минимальное значения.
Для компиляции использовались компиляторы Intel 13й версии и NVCC CUDA 5.5 с флагами –O3 –arch=sm_35.
В качестве результата проделанной работы рассмотрим следующий график:
На данном графике изображены кривые средней производительности для следующих алгоритмов:
Видно, что обе оптимизации сильно влияют на производительность. Стоит отметить, что неверное использование оптимизации const __restrict может ухудшить производительность. Полученное ускорение можно увидеть на данном графике:
По приведенному графику видно, что в отличие от CPU, на GPU наблюдается бОльший диапазон отклонений от среднего. Это связано с особенностью реализации алгоритма в виде «гонки» потоков, так как от запуска к запуску можем получить разное количество итераций.
В результате проделанной работы был разработан, реализован и оптимизирован алгоритм SSSP — поиска кратчайших путей в графе. Данный алгоритм ищет кратчайшие расстояния в графе от заданной вершины до всех остальных. Среди всех графов, которые поместились в память GTX Titan максимальную производительность показывают графы с количеством вершин до 221 — порядка 1100 млн ребер в секунду. Максимальное среднее ускорение, которое удалось достичь — порядка 40 на графах с количеством вершин от 1 до 4 млн.
Автор: ALEX_k_s
Источник [7]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/intel/56297
Ссылки в тексте:
[1] Kepler: http://www.nvidia.ru/object/nvidia-kepler-ru.html
[2] CUDA: http://www.nvidia.ru/object/cuda-parallel-computing-ru.html
[3] Форда-Беллмана: http://e-maxx.ru/algo/ford_bellman
[4] RMAT-графы: http://dislab.org/GraphHPC-2014/rmat-siam04.pdf
[5] Граф: http://ru.wikipedia.org/wiki/%C3%F0%E0%F4_(%EC%E0%F2%E5%EC%E0%F2%E8%EA%E0)
[6] Базовый алгоритм Дейкстры: http://e-maxx.ru/algo/dijkstra
[7] Источник: http://habrahabr.ru/post/214515/
Нажмите здесь для печати.