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

Для текущего проекта OpenCL GPGPU мне нужно отсортировать элементы в массиве по некоторому ключу с 64 возможными значениями. Мне нужно, чтобы в последнем массиве все элементы с одним и тем же ключом были смежными. Достаточно иметь ассоциативный массив new_index[old_index] в качестве выхода этой задачи.

Я разделил задачу на две части. Сначала я подсчитываю для каждого возможного ключа (корзины) количество элементов с этим ключом (которые попадают в эту корзину). Я просматриваю этот массив (генерирую сумму префиксов), который указывает новый диапазон индексов элементов для каждой корзины, например "начальные" индексы для каждой корзины.

На втором этапе каждому элементу необходимо присвоить новый индекс. Если бы я реализовал это на процессоре, алгоритм был бы примерно таким:

for all elements e:
    new_index[e] = bucket_start[bucket(e)]++

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

Идея состоит в том, чтобы провести вычисления в рабочих группах. Но я не уверен, как именно это должно быть сделано, поскольку у меня нет опыта в вычислениях GPGPU.

В глобальной памяти у нас есть массив начала сегмента, инициализированный префиксной суммой, как указано выше. Доступ к этому массиву «мьютексирован» с помощью атомарного int. (Я новичок в этом, так что, возможно, смешаю здесь несколько слов.)

Каждой рабочей группе неявно назначается часть массива входных элементов. Он использует локальный массив сегментов, содержащий новые индексы относительно начала (глобального) сегмента, о котором мы еще не знаем. После заполнения одного из этих «локальных буферов» рабочая группа должна записать локальные буферы в глобальный массив. Для этого он блокирует доступ к глобальному массиву начала сегмента, увеличивает эти значения на текущие размеры локального сегмента, разблокирует, а затем может записать результат в глобальный массив new_index (путем добавления соответствующего смещения). Этот процесс повторяется до тех пор, пока не будут обработаны все присвоенные элементы.

Возникают два вопроса:

  1. Это хороший подход? Я знаю, что чтение и запись из / в глобальную память, скорее всего, является узким местом здесь, тем более что я пытаюсь получить синхронизированный доступ к (по крайней мере, только небольшой части ) глобальная память. Но, возможно, есть гораздо лучший подход для этого, возможно, используя декомпозицию ядра. Обратите внимание, что я стараюсь избегать обратного чтения данных из GPU в CPU во время ядер (чтобы избежать сброса очереди команд OpenCL, что тоже плохо, как меня учили).

  2. В приведенном выше алгоритме как реализовать механизм блокировки? Будет ли работать что-то вроде следующего кода? В частности, я ожидаю проблем, когда оборудование выполняет рабочие элементы «по-настоящему параллельно» в группах SIMD, например, «перекосы» Nvidia. В моем текущем коде все элементы рабочей группы будут пытаться получить блокировку в режиме SIMD. Следует ли ограничить это только первым рабочим элементом? И использовать барьеры, чтобы синхронизировать их локально?

    #pragma OPENCL EXTENSION cl_khr_global_int32_base_atomics : enable
    
    __kernel void putInBuckets(__global uint *mutex,
                               __global uint *bucket_start,
                               __global uint *new_index)
    {
        __local bucket_size[NUM_BUCKETS];
        __local bucket[NUM_BUCKETS][LOCAL_MAX_BUCKET_SIZE]; // local "new_index"
    
        while (...)
        {
            // process a couple of elements locally until a local bucket is full
            ...
    
            // "lock"
            while(atomic_xchg(mutex, 1)) {
            }
    
            // "critical section"
            __local uint l_bucket_start[NUM_BUCKETS];
            for (int b = 0; b < NUM_BUCKETS; ++b) {
                l_bucket_start[b] = bucket_start[b]; // where should we write?
                bucket_start[b] += bucket_size[b];   // update global offset
            }
    
            // "unlock"
            atomic_xchg(mutex, 0);
    
            // write to global memory by adding the offset
            for (...)
                new_index[...] = ... + l_bucket_start[b];
        }
    }
    

person leemes    schedule 27.05.2013    source источник


Ответы (3)


Во-первых, никогда не пытайтесь реализовать алгоритм блокировки на GPU. Он зайдет в тупик и заглохнет. Это связано с тем, что графический процессор является устройством SIMD, и потоки не выполняются независимо, как на ЦП. Графический процессор синхронно выполняет набор потоков, называемых WARP / WaveFront. Таким образом, если один поток на волновом фронте останавливается, он останавливает все другие потоки на волновом фронте. Если поток разблокировки находится в остановленном волновом фронте, он НЕ будет выполнять и разблокировать мьютекс.

Атомарные операции в порядке.

Что вам следует учитывать, так это подход без блокировок. См. Этот документ для объяснения и образца кода CUDA: http://www.cse.iitk.ac.in/users/mainakc/pub/icpads2012.pdf/

Он описывает хэш-таблицы без блокировки, связанный список и списки пропуска с некоторым образцом кода CUDA.

Предлагаемый подход - создать двухуровневую структуру данных.

Первый уровень - это список бесплатного пропуска блокировок. Каждая запись списка пропусков имеет структуру второго уровня - свободный от блокировок связанный список для повторяющихся значений. И атомарный счет количества записей.

Метод вставки

1) Сгенерировать 64 ключа сегмента 2) Найти ключ в списке пропуска 3) Если не найден, вставить в список пропуска 4) Вставить данные в связанный список 5) увеличить атомарный счетчик для этого сегмента

После вставки префикса просуммируйте все счетчики сегментов списка пропуска, чтобы найти смещение вывода.

person Tim Child    schedule 12.06.2013

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

Первый шаг - присвоить индекс в глобальном целевом массиве, куда каждый поток будет записывать свои элементы. Для этого мы можем использовать в _1 _, чтобы добавить количество добавляемых элементов. Используйте эту функцию на bucket_start в этом конкретном примере. Возвращаемое значение atomic_add - это старое значение.

На втором этапе мы используем это возвращаемое значение в качестве базового индекса для копирования локальных буферов в целевом массиве. Если мы решаем использовать всю группу потоков для одной такой операции добавления, мы распространяем копирование локального буфера в глобальный массив внутри группы потоков «как обычно». В приведенном выше примере сортировки сегментов мы копируем несколько массивов, и когда количество массивов (= количество сегментов) равно размеру рабочей группы, мы можем вместо этого назначить каждому потоку одну корзину, которая будет копироваться в цикле.

person leemes    schedule 27.10.2014

Недавно мне пришлось решить похожую проблему, и я нашел гораздо более элегантное и эффективное решение. Думал поделюсь.

Общий алгоритм следующий:

1. ядро 1: поток на элемент

  • Подсчитайте количество элементов в каждом сегменте (гистограмма).
  • Для каждого элемента: вычислить смещение каждого значения от начала ведра (сложная часть).

2. ядро 2: поток на ведро

  • сумма префикса (сканирование) на гистограмме для расчета начала каждого сегмента

3. ядро 3: поток на элемент

  • разбросать элементы.

    для каждого элемента i на входе: output [i] = prefix_sum [input [i]] + offsets [i];

Сложная часть - это сгенерировать массив смещений, который мы используем в третьем ядре.

В первом ядре мы определяем локальный кеш, который содержит гистограмму сегментов для каждой рабочей группы. Я использую тот факт, что atomic_add возвращает предыдущее значение этого счетчика - «текущее» смещение. Этот факт является ключевым.

__kernel void bucket_histogram(__global uint *input,__global uint *histogram,__global uint *offsets) {

__local local_histogram[NUM_BUCKETS];

size_t local_idx = get_local_id(0);
size_t global_idx = get_global_id(0);

// zero local mem

if (local_idx < NUM_BUCKETS)
{
    local_histogram[local_idx] = 0;
}
barrier(CLK_LOCAL_MEM_FENCE);

// increment local histogram, save the local offset for later
uint value = input[global_idx];
uint local_offset = atomic_add(&local_histogram[value], 1);

barrier(CLK_LOCAL_MEM_FENCE);

// store the buckets in the global histogram (for later prefix sum)

if (local_idx < NUM_BUCKETS)
{
    uint count = local_histogram[local_idx];
    if (count > 0)
    {
        // increment the global histogram, save the offset!
        uint group_offset_for_the_value_local_idx = atomic_add(&histogram[local_idx], count);
        local_histogram[local_idx] = group_offset_for_the_value_local_idx;
    }
}

barrier(CLK_LOCAL_MEM_FENCE);

// now local_histogram changes roles, it contains the per-value group offset from the start of the bucket

offsets[global_idx] = local_offset + local_histogram[value];

Второе ядро ​​преобразует сумму префикса для вычисления начала каждой корзины. Третье ядро ​​просто объединяет все смещения:

__kernel void bucket_sort_scatter(__global uint *input, __global uint* prefix_sum_histogram, __global uint* offsets, __global data_t *output) {

size_t global_idx = get_global_id(0);
uint value = input[global_idx];
uint scatter_target = offsets[global_idx] + prefix_sum_histogram[value];
output[scatter_target] = value;
person Elad Maimoni    schedule 15.11.2020