Операции трафарета на графическом процессоре

Этот пример использует «Игру жизни» Конвея, чтобы продемонстрировать, как операции трафарета могут выполняться с помощью графический процессор.

Многие операции над массивами могут быть выражены как «операция трафарета», где каждый элемент выхода массива зависит от небольшой области входа массива. Примеры включают конечные различия, свертки, медианную фильтрацию и конечноэлементные методы. Этот пример использует «Игру жизни» Конвея, чтобы продемонстрировать два способа запустить операцию трафарета на графическом процессоре, начиная с кода в электронной книге Клева Молера «Эксперименты в MATLAB».

«Игра жизни» следует нескольким простым правилам:

  • Камеры расположены в 2D сетке

  • На каждом шаге судьба каждой камеры определяется жизнеспособностью восьми ее ближайших соседей

  • Любая камера с точно тремя живыми соседями оживает на следующем шаге

  • Живая камера с ровно двумя живыми соседями остается живой на следующем шаге

  • Все другие камеры (включая клетки с более чем тремя соседями) умирают на следующем шаге или остаются пустыми

«Трафарет» в этом случае является областью 3x3 вокруг каждого элемента. Вот несколько примеров обновления камеры:

Этот пример является функцией, позволяющей использовать вложенные функции:

function paralleldemo_gpu_stencil()

Сгенерируйте случайную начальную генеральную совокупность

На 2D сетке создается начальная генеральная совокупность камер с примерно 25% мест.

gridSize = 500;
numGenerations = 100;
initialGrid = (rand(gridSize,gridSize) > .75);
gpu = gpuDevice();

% Draw the initial grid
hold off
imagesc(initialGrid);
colormap([1 1 1;0 0.5 0]);
title('Initial Grid');

Игра в игру жизни

Электронная книга Experiments in MATLAB обеспечивает начальную реализацию, которая может использоваться для сравнения. Эта версия полностью векторизирована, обновляя все камеры в сетке за один проход на генерацию.

    function X = updateGrid(X, N)
        p = [1 1:N-1];
        q = [2:N N];
        % Count how many of the eight neighbors are alive.
        neighbors = X(:,p) + X(:,q) + X(p,:) + X(q,:) + ...
            X(p,p) + X(q,q) + X(p,q) + X(q,p);
        % A live cell with two live neighbors, or any cell with
        % three live neighbors, is alive at the next step.
        X = (X & (neighbors == 2)) | (neighbors == 3);
    end

grid = initialGrid;
% Loop through each generation updating the grid and displaying it
for generation = 1:numGenerations
    grid = updateGrid(grid, gridSize);

    imagesc(grid);
    title(num2str(generation));
    drawnow;
end

Теперь перезапустите игру и измерьте, сколько времени это занимает для каждой генерации.

grid = initialGrid;
timer = tic();

for generation = 1:numGenerations
    grid = updateGrid(grid, gridSize);
end

cpuTime = toc(timer);
fprintf('Average time on the CPU: %2.3fms per generation.\n', ...
    1000*cpuTime/numGenerations);
Average time on the CPU: 11.323ms per generation.

Сохраните этот результат, чтобы проверить правильность каждой версии ниже.

expectedResult = grid;

Преобразование игры в жизнь для запуска на графическом процессоре

Чтобы запустить Игру Жизни на графическом процессоре, начальная генеральная совокупность отправляется на графический процессор с помощью gpuArray. Алгоритм остается неизменным. Обратите внимание, что wait (GPUDevice) используется для проверки завершения вычисления графического процессора перед остановкой таймера. Это требуется только для точной синхронизации.

grid = gpuArray(initialGrid);
timer = tic();

for generation = 1:numGenerations
    grid = updateGrid(grid, gridSize);
end

wait(gpu); % Only needed to ensure accurate timing
gpuSimpleTime = toc(timer);

% Print out the average computation time and check the result is unchanged.
fprintf(['Average time on the GPU: %2.3fms per generation ', ...
    '(%1.1fx faster).\n'], ...
    1000*gpuSimpleTime/numGenerations, cpuTime/gpuSimpleTime);
assert(isequal(grid, expectedResult));
Average time on the GPU: 1.655ms per generation (6.8x faster).

Создание поэлементной версии для графического процессора

Смотрите на расчеты в updateGrid очевидно, что одни и те же операции применяются к каждому местоположению сетки независимо. Это говорит о том, что arrayfun можно использовать для проведения оценки. Однако каждая камера должна знать о своих восьми соседях, нарушая поэлементную независимость. Каждый элемент должен иметь возможность доступа к полной сетке, одновременно работая независимо.

Решением является использование вложенной функции. Вложенные функции, даже те, что используются с arrayfun, может получить доступ к переменным, объявленным в их родительской функции. Это означает, что каждая камера может считывать всю сетку с предыдущего временного шага и указывать в нее.

grid = gpuArray(initialGrid);

    function X = updateParentGrid(row, col, N)
        % Take account of boundary effects
        rowU = max(1,row-1);  rowD = min(N,row+1);
        colL = max(1,col-1);  colR = min(N,col+1);
        % Count neighbors
        neighbors ...
            = grid(rowU,colL) + grid(row,colL) + grid(rowD,colL) ...
            + grid(rowU,col)                   + grid(rowD,col) ...
            + grid(rowU,colR) + grid(row,colR) + grid(rowD,colR);
        % A live cell with two live neighbors, or any cell with
        % three live neighbors, is alive at the next step.
        X = (grid(row,col) & (neighbors == 2)) | (neighbors == 3);
    end


timer = tic();

rows = gpuArray.colon(1, gridSize)';
cols = gpuArray.colon(1, gridSize);
for generation = 1:numGenerations
    grid = arrayfun(@updateParentGrid, rows, cols, gridSize);
end

wait(gpu); % Only needed to ensure accurate timing
gpuArrayfunTime = toc(timer);

% Print out the average computation time and check the result is unchanged.
fprintf(['Average time using GPU arrayfun: %2.3fms per generation ', ...
    '(%1.1fx faster).\n'], ...
    1000*gpuArrayfunTime/numGenerations, cpuTime/gpuArrayfunTime);
assert(isequal(grid, expectedResult));
Average time using GPU arrayfun: 0.795ms per generation (14.2x faster).

Обратите внимание, что мы также использовали другую новую возможность arrayfun здесь: расширение размерности. Нам нужно было передать только векторы строка и столбец, и они были автоматически расширены в полную сетку. Эффект, как будто мы называли:

[cols,rows] = meshgrid(cols,rows);

как часть arrayfun вызов. Это сохраняет нам и некоторые расчеты, и некоторую передачу данных между памятью центральный процессор и памятью графический процессор.

Заключение

В этом примере на графическом процессоре была реализована простая операция трафарета, «Игра жизни» Конвея arrayfun и переменные, объявленные в родительской функции. Этот метод может использоваться, чтобы реализовать область значений операций трафарета, включая конечноэлементные алгоритмы, свертки и фильтры. Он также может использоваться для доступа к элементам в интерполяционной таблице, определенной в родительской функции.

fprintf('CPU:          %2.3fms per generation.\n', ...
    1000*cpuTime/numGenerations);
fprintf('Simple GPU:   %2.3fms per generation (%1.1fx faster).\n', ...
    1000*gpuSimpleTime/numGenerations, cpuTime/gpuSimpleTime);
fprintf('Arrayfun GPU: %2.3fms per generation (%1.1fx faster).\n', ...
    1000*gpuArrayfunTime/numGenerations, cpuTime/gpuArrayfunTime);
CPU:          11.323ms per generation.
Simple GPU:   1.655ms per generation (6.8x faster).
Arrayfun GPU: 0.795ms per generation (14.2x faster).
end