Главная Статьи Ссылки Скачать Скриншоты Юмор Почитать Tools Проекты Обо мне Гостевая Форум |
Я уже рассматривал на сайте битоническую сортировку при помощи фрагментных шейдеров. Она работает, но на данный момент это далеко не самый оптимальный вариант выполнения сортировки на GPU. Поэтому здесь будет рассмотрен модифицированный вариант битонической сортировки и его реализация при помощи вычислительных шейдеров. Одним из основных недостатков битонической сортировки является большое число обращений к DRAM GPU, поэтому мы рассмотрим как можно использовать быструю разделяемую (shared) память для увеличения быстродействия.
В классическом варианте битонической сортировки строится так называемая сортировочная сеть, состоящая из полуочистителей (half-cleaner). Каждый полуочиститель получает на вход два элемента и упорядочивает их по возрастанию или по убыванию. В классической схеме направления сортировки все время чередуются, что не очень удобно.
Последовательность a0,...,an-1 называется битонической если она или состоит из двух монотонных частей (одна часть возрастает, другая убывает) или получается циклическим вращением такой последовательности.
Если последовательность битоническая то при помощи битонического слияния (bitonic merge) ее можно легко отсортировать. Битоническое слияние это последовательное применение наборов полуочистителей. На самом деле произвольная последовательность может быть приведена к битонической следующим путем.
Применим п парам подряд идущих элементов полуочиститель, сортирующий их. Только сделаем так, чтобы первая пара была упорядоченна по возрастанию, следующая пара по убыванию и т.д. Тогда у нас будет набор битонических последовательностей из 4 элементов каждая. Каждую из них можно отсортировать битоническим слиянием опять чередуя направление сортировки.
В результате мы получим набор битонических последовательностей из 8 элементов. Их мы отсортируем опять чередуя направление. Тогда мы получим набор битонических последовательностей из 16 элементов и т.д. до тех пор пока мы не придем к полностью отсортированной последовательности.
Но на самом деле существует вариант битонической сортировки в котором вообще нет чередования направлений и все полуочистители всегда сортируют в одном и том же направлении что приводит к более чистому и легко читаемому коду. На рис. 1 приводится схема подобной битонической сортировки для 16 элементов. Каждый вертикальный отрезок на рисунке соответствует одному полуочистителю, выполняющему операцию compare-and-swap (CAS). Обратите внимание, что на самом деле нитей нужно вдвое меньше, чем у нас входных элементов, т.е. 8.
Рис. 1. Схема битонической сортировки 16 элементов без чередования направления
Как видно из рисунка вся сортировка состоит из ряда шагов, на каждом из которых выполняется операция compare-and-swap над всеми элементами входного массива. Разница между шагами заключается в том, как строятся пары сравниваемых элементов. На рисунке видно 10 таких шагов, причем эти шаги делятся на две группы, обозначенные на рисунке зеленым и желтым. И также видно что каждый шаг характеризуется своей высотой - расстоянием начиная с которого мы начинаем сравнивать элементы.
На зеленом шаге мы работаем с группами элементов и для каждого следующего шага высота (т.е. количество элементов в группе h) удваивается.
Внутри каждой группы мы сравниваем элементы,
симметрично расположенные относительно середины группы.
Таким образом если у нас есть группа из h элементов as,as+1,...,as+h-1,
то мы будем сравнивать между собой пары (as+j,ss+h-1-j) для
j от 0 до h/2.
Данный шаг мы будем называть flip
, ниже приводится его реализация на С++.
void flip ( int n, int h )
{
for ( int t = 0; t < n/2; t++ )
{
int q = ((2 * t) / h) * h;
int t2 = t % (h/2);
compareAndSwap ( q + t2, q + h - t2 - 1 );
}
}
За каждый зеленым блоком (flip) обязательно следует желтый блок. Каждый такой блок также работает с группами по h элементов. И с каждым
новым шагом h уменьшается вдвое.
Если группа состоит из элементов as,as+1,...,as+h-1, то мы будем сравнивать между собой пары
(as+j, as+j+h/2). Желтый шаг мы будем называть disperse
.
Ниже приводится реализация желтого шага на С++.
void disperse ( int n, int h )
{
for ( int t = 0; t < n/2; t++ )
{
uint q = ((2 * t) / h) * h;
uint t2 = t % (h/2);
compareAndSwap ( q + t2, q + t2 + h/2 );
}
}
Тогда весь процесс сортировки массива из n элементов (считая n степенью двух) можно записать на С++ следующим образом:
for ( int h = 2; h <= n; h *= 2 )
{
flip ( n, h );
for ( int hh = h / 2; hh > 1; hh /= 2 )
disperse ( n, hh );
}
Теперь давайте перепишем ранее приведенный код в виде вычислительного шейдера на GLSL. Для начала давайте рассмотрим реализацию битонической сортировки при помощи разделяемой памяти. Поскольку объем разделяемой памяти, который может быть выделен одному блоку ограничен, то мы начнем со случая, когда число сортируемый элементов n равно 1024. В этом случае мы гарантированно можем разместить весь массив в разделяемой памяти GPU и нам понадобится всего один блок из 512 нитей (на 1024 сортируемый элементов).
Основным свойством разделяемой памяти является то,что она выделяется на каждый блок и является очень быстрой. Но при этом блок
не может обратиться к разделяемой памяти другого блока.
На n элементов входного массива мы запустим один блок из n/2 нитей, каждая нить при этом отвечает за два элемента массива.
Ниже приводится соответствующий шейдер, обратите внимание на обязательное использование барьерной синхронизации - вызовы функции
barrier
. Она нужно потому, что нити блока могут находится в разных местах кода, какие-то могут убежать вперед, а какие-то наоборот отстать.
Гарантируется, что функцию barrier
все нити блока пройдут одновременно.
-- compute
#version 450 core
// Sort 16 sortable elements
#define NUM_ELEMENTS 16
layout ( local_size_x = NUM_ELEMENTS / 2 ) in;
layout ( binding = 0 ) buffer SortData
{
uint value [];
};
// Use local memory to minimize memory access cost.
shared uint local [NUM_ELEMENTS];
void localCompareAndSwap ( uvec2 idx )
{
if ( local [idx.x] > local [idx.y] )
{
uint tmp = local [idx.x];
local [idx.x] = local [idx.y];
local [idx.y] = tmp;
}
}
void flip ( uint h )
{
uint t = gl_LocalInvocationID.x;
uint q = ((2 * t) / h) * h;
uint t2 = t % (h / 2);
uvec2 indices = q + uvec2 ( t2, h - t2 - 1 );
localCompareAndSwap ( indices );
}
void disperse ( uint h )
{
uint t = gl_LocalInvocationID.x;
uint q = ((2 * t) / h) * h;
uint t2 = t % (h / 2);
uvec2 indices = q + uvec2 ( t2, t2 + (h / 2) );
localCompareAndSwap ( indices );
}
void main ()
{
uint t = gl_LocalInvocationID.x;
int n = NUM_ELEMENTS;
// each thread loads two elements to shared memory
local [t*2 ] = value [t*2 ];
local [t*2 + 1] = value [t*2 + 1];
for ( uint h = 2; h <= n; h *= 2 )
{
barrier ();
flip ( h );
for ( uint hh = h / 2; hh > 1; hh /= 2 )
{
barrier ();
disperse ( hh );
}
}
barrier ();
// write data back
value [t*2 ] = local [t*2 ];
value [t*2 + 1] = local [t*2 + 1];
}
Приведенный выше шейдер легко выполнит битоническую сортировку 1024 элементов с использованием разделяемой памяти.
Использование быстрой разделяемой памяти важно, поскольку у нас имеет место большое число обращений к памяти.
Однако данный шейдер может отсортировать только массив небольшого размера, который целиком поместится в разделяемую память.
Можно пойти другим путем - все операции проводить с SSBO вообще не используя разделяемую память.
Для этого нужно использовать приведенные ниже версии функций bigFlip
и bigDisperse
, которые работают напрямую с SSBO.
// Performs full-height flip (h height) over globally available indices.
void bigFlip ( in uint h )
{
uint tPrime = gl_GlobalInvocationID.x;
uint halfH = h >> 1;
uint q = ((2 * tPrime) / h) * h;
uint t2 = tPrime % halfH;
uint x = q + t2;
uint y = q + h - t2 - 1;
globalCompareAndSwap ( ivec2 ( x, y ) );
}
// full-height disperse (h height) over globally available indices.
void bigDisperse ( in uint h )
{
uint tPrime = gl_GlobalInvocationID.x;
uint halfH = h >> 1;
uint q = ((2 * tPrime) / h) * h;
uint t2 = tPrime % halfH;
uint x = q + t2;
uint y = q + t2 + halfH;
globalCompareAndSwap ( ivec2 ( x, y ) );
}
Однако понятно, что быстродействие у такого подхода будет не очень высоким из-за большого числа обращений к довольно медленной DRAM GPU. Однако можно заметно повысить эффективность за счет грамотного использования разделяемой памяти. Для этого давайте представим что мы по-прежнему сортируем 16 элементов, но теперь у нас есть два блока по 4 нити и у каждого блока свой участок разделяемой памяти не доступный другому блоку.
Рис. 2. Битоническая сортировка 16 элементов при помощи двух блоков по 4 нити в каждом.
Как видно из рисунка у нас для 16 элементов есть только один шаг, где нужно нитям одного блока обращаться к памяти другого блока -
это bigFlip
. Все остальные шаги могут быть реализованы через разделяемую память.
В общем случае у нас кроме bigFlip
появится еще и bigDisperse
.
Но при этом все что можно сделать используя разделяемую память будет
делаться с использованием разделяемой памяти.
В результате у нас появится четыре операции - localSort
, disperse
, bigFlip
и bigDisperse
.
Из них первые две работают с разделяемой памятью, вторые две - с DRAM GPU.
Операция localSort
это на самом деле объединенные вместе несколько операций flip
disperse
.
Можно эти четыре операции разбить на четыре шейдера, здесь мы будем использовать один большой шейдер, реализующий сразу все
эти четыре операции.
Какую из них нужно выполнить в данный момент будет задаваться через uniform-переменную algorithm
.
Подобный подход позволит уменьшить число дорогостоящих переключений между шейдерами.
Далее приводится соответствующий вычислительный шейдер.
-- compute
#version 450 core
#define LocalBitonicMergeSort 0
#define LocalDisperse 1
#define BigFlip 2
#define BigDisperse 3
layout ( local_size_x = 512 ) in;
layout ( binding = 0 ) buffer SortData
{
uint value [];
};
uniform int step;
uniform int algorithm;
shared uint local_value [gl_WorkGroupSize.x * 2];
bool is_smaller ( in const uint left, in const uint right )
{
return left > right;
}
#define COMPARE is_smaller
void globalCompareAndSwap ( ivec2 idx )
{
if ( COMPARE ( value [idx.x], value [idx.y] ) )
{
uint tmp = value [idx.x];
value [idx.x] = value [idx.y];
value [idx.y] = tmp;
}
}
void localCompareAndSwap ( ivec2 idx )
{
if ( COMPARE ( local_value [idx.x], local_value [idx.y] ) )
{
uint tmp = local_value [idx.x];
local_value [idx.x] = local_value [idx.y];
local_value [idx.y] = tmp;
}
}
// Performs full-height flip (h height) over globally available indices.
void bigFlip ( in uint h )
{
uint tPrime = gl_GlobalInvocationID.x;
uint halfH = h >> 1;
uint q = ((2 * tPrime) / h) * h;
uint t2 = tPrime % halfH;
uint x = q + t2;
uint y = q + h - t2 - 1;
globalCompareAndSwap ( ivec2 ( x, y ) );
}
// full-height disperse (h height) over globally available indices.
void bigDisperse ( in uint h )
{
uint tPrime = gl_GlobalInvocationID.x;
uint halfH = h >> 1;
uint q = ((2 * tPrime) / h) * h;
uint t2 = tPrime % halfH;
uint x = q + t2;
uint y = q + t2 + halfH;
globalCompareAndSwap ( ivec2 ( x, y ) );
}
// Performs full-height flip (h height) over locally available indices.
void localFlip ( in uint h )
{
uint t = gl_LocalInvocationID.x;
uint halfH = h >> 1;
uint t2 = t % halfH;
uint q = h * ( ( 2 * t ) / h );
barrier ();
localCompareAndSwap ( ivec2 ( q + t2, q + h - 1 - t2 ) );
}
// Performs progressively diminishing disperse operations (starting with height h)
// on locally available indices: e.g. h==8 -> 8 : 4 : 2.
// One disperse operation for every time we can divide h by 2.
void localDisperse ( in uint h )
{
uint t = gl_LocalInvocationID.x;
for ( ; h > 1 ; h /= 2 )
{
barrier ();
uint halfH = h >> 1;
uint t2 = t % halfH;
uint q = h * ( ( 2 * t ) / h );
localCompareAndSwap ( ivec2 ( q + t2, q + halfH + t2 ) );
}
}
// binary merge sort for local elements
void localSort ( in uint h )
{
for ( uint hh = 2; hh <= h; hh <<= 1 )
{
localFlip ( hh );
localDisperse ( hh / 2 );
}
}
void main ()
{
uint t = gl_LocalInvocationID.x;
// Calculate global offset for local workgroup
uint offset = gl_WorkGroupSize.x * 2 * gl_WorkGroupID.x;
// This shader can be called in four different modes:
//
// 1. local flip+disperse (up to n == local_size_x * 2)
// 2. big flip
// 3. big disperse
// 4. local disperse
if ( algorithm <= LocalDisperse )
{
// for local store data in shared memory
local_value [t*2 ] = value [offset + t*2 ];
local_value [t*2 + 1] = value [offset + t*2 + 1];
}
switch ( algorithm )
{
case LocalBitonicMergeSort:
localSort ( step );
break;
case LocalDisperse:
localDisperse ( step );
break;
case BigFlip:
bigFlip ( step );
break;
case BigDisperse:
bigDisperse ( step );
break;
}
// write shared data back to buffer
if ( algorithm <= LocalDisperse )
{
barrier ();
// push to global memory
value [offset + t*2 ] = local_value [t*2 ];
value [offset + t*2 + 1] = local_value [t*2 + 1];
}
}
Ниже приводится код на С++, реализующий битоническую сортировку при помощи описанного шейдера.
Обратите внимание что в используемом классе содержится сразу две метода sortSlow
и sort
.
Первый реализует сортировку без использования разделяемой памяти, а второй - с использованием.
#define NUM_ELEMENTS (1024*1024) // 1M elements
#define LocalBitonicMergeSort 0
#define LocalDisperse 1
#define BigFlip 2
#define BigDisperse 3
class SortWindow : public GlutWindow
{
Program program;
VertexArray vao;
VertexBuffer buf;
std::vector<uint32_t> array;
const int blockSize = 512; // # of threads in a block
public:
SortWindow () : GlutWindow ( 100, 100, 512, 512, "Compute Sort" )
{
// initialize array with random elements to sort
array.resize ( NUM_ELEMENTS );
for ( int i = 0; i < NUM_ELEMENTS; i++ )
array [i] = rand ();
// load sorting compute shader
if ( !program.loadProgram ( "global-sort.glsl" ) )
exit ( "Error building program: %s\n", program.getLog ().c_str () );
// prepare buffer with data to be sorted
program.bind ();
vao.create ();
vao.bind ();
buf.create ();
buf.bindBase ( GL_SHADER_STORAGE_BUFFER, 0 );
buf.setData ( NUM_ELEMENTS * sizeof ( uint32_t ), array.data (), GL_DYNAMIC_DRAW );
// perform the sort
sort ( NUM_ELEMENTS );
program.unbind ();
// get sorted array
std::vector<uint32_t> buffer ( NUM_ELEMENTS );
glGetNamedBufferSubData ( buf.getId (), 0, NUM_ELEMENTS * sizeof ( uint32_t ), buffer.data () );
// sort original data for comparison
std::sort ( array.begin (), array.end () );
// print the diffs
printf ( "\nDiffs:\n" );
for ( int i = 0; i < NUM_ELEMENTS; i++ )
if ( buffer [i] != array [i] )
printf ( "Diff aat index %d - %d vs %d\n", i, array [i], buffer [i] );
}
void redisplay () override
{
glClear ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT );
}
void dispatch ( int n, int h, int algo )
{
const int blockCount = n / ( blockSize * 2 );
program.setUniformInt ( "step", h );
program.setUniformInt ( "algorithm", algo );
glDispatchCompute ( blockCount, 1, 1 );
glMemoryBarrier ( GL_SHADER_STORAGE_BARRIER_BIT );
}
void sort ( int n )
{
int h = blockSize * 2;
assert ( h <= n );
assert ( h % 2 == 0 );
dispatch ( n, h, LocalBitonicMergeSort );
for ( h *= 2; h <= n; h *= 2 )
{
dispatch ( n, h, BigFlip );
for ( int hh = h / 2; hh > 1; hh /= 2 )
{
if ( hh <= blockSize * 2 )
{
dispatch ( n, hh, LocalDisperse );
break;
}
else
dispatch ( n, hh, BigDisperse );
}
}
}
void sortSlow ( int n )
{
for ( int h = 2; h <= n; h *= 2 )
{
dispatch ( n, h, BigFlip );
glMemoryBarrier ( GL_SHADER_STORAGE_BARRIER_BIT );
for ( int hh = h / 2; hh > 1; hh /= 2 )
{
dispatch ( n, hh, BigDisperse );
glMemoryBarrier ( GL_SHADER_STORAGE_BARRIER_BIT );
}
}
}
};
int main ( int argc, char * argv [] )
{
GlutWindow::init ( argc, argv );
SortWindow win;
return 0;
}
Исходный код к этой статье можно скачать по этой ссылке. В основу данной статьи легла следующая статья на английском языке.