Storage Buffer - массив int, используемый с [y] [x], в 3 раза быстрее, чем с помощью [x] [y]? - PullRequest
1 голос
/ 20 мая 2019

В качестве тестового проекта я написал базовую игру Consway с помощью компьютерных шейдеров (Vulkan). В основном:

  • «Доска» хранится в массиве int внутри буфера хранения.
#define WIDTH 800
#define HEIGHT 600
#define WORKGROUP_SIZE 32

layout (local_size_x = WORKGROUP_SIZE, local_size_y = WORKGROUP_SIZE, local_size_z = 1) in;

layout(binding = 0) readonly buffer buf1 {
   int data[WIDTH][HEIGHT];
} previousBoard;

layout(binding = 1) buffer buf2 {
   int data[WIDTH][HEIGHT];
} nextBoard;
  • Вычисляющий шейдер затем обновляет его каждый кадр (один вызов на пиксель).

Я случайно сделал некоторые изменения и заметил, что если я получу доступ к массиву с помощью data[y][x] (из gl_GlobalInvocationID.xy), моя программа будет * на 1013 * 3 раза быстрее , чем обычный доступ с data[x][y] ( по крайней мере, на моем компьютере (intel UHD 620) у меня 500 кадров в секунду с [x] [y], против 1700 кадров в секунду с [y] [x]).

Я потратил несколько часов, чтобы изолировать это поведение, чтобы убедиться, что оно не было побочным эффектом. Я даже разобрал код Spir-v, но не нашел ничего интересного, что могло бы помочь мне понять. Здесь diff шейдера (с [x] [y] и с [y] [x]): https://www.diffchecker.com/vFlkEsQp.

Я далеко не понимаю, что здесь происходит. Есть ли причина, объясняющая такой разрыв в производительности?

Мне не очень нравится использовать [y] [x] (или я должен?), Поэтому у меня есть другой способ добиться аналогичных результатов с [x] [y]?

1 Ответ

7 голосов
/ 20 мая 2019

Это почти наверняка вопрос когерентности кэша. В GLSL int[WIDTH][HEIGHT] - это массив HEIGHT 1D массивов WIDTH int с. Это главный ряд. Поэтому, если вы выбираете previousBoard.data[0][0], вы выбираете строку кэша (при условии 32 байта), которая может включать следующие 7 элементов первой строки и none второй строки.

Ваш шейдер сам выполняется в 2D-модели с 32x32 вызовами в рабочей группе. Если графический процессор выполняет вызовы первой строки (от 0,0 до 31,0) в одно и то же время, ему потребуется только 4 фактических выборки памяти. Теперь, конечно, чтобы выполнить алгоритм для всех этих записей, вам понадобится также предыдущая строка и следующая строка, а также строка кэша для адресов памяти справа.

Итак, вам нужно 15 выборок памяти. Это может звучать как много.

Но давайте рассмотрим случай, когда графический процессор выполняет вызовы первого столбца *1015*: от 0,0 до 0,31. Ну, сколько вам нужно? Вам нужно 33 (+1 для строки ниже дна), более чем вдвое больше. Помните: строки кэша являются основными строками, а не ориентированы на столбцы.

И, конечно, вам понадобится столько же записей в кэш-памяти.

При этом порядок вызовов в первом столбце должен быть в состоянии повысить производительность, поскольку вызовы второго столбца должны получать те же строки кэша, что и первый. Но это предполагает, что реализация будет выполнять вызовы второго столбца одновременно. Если вместо этого он решит заполнить свои исполнительные блоки большим количеством рабочих групп (то есть он выполняет столбец 0, столбец 32, столбец 64, столбец 96 и т. Д.), То у вас также может не быть кеша.

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

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

Во-первых, поскольку нет никакой зависимости между вашими вызовами в рабочей группе, вы не должны делать ваш local_size двухмерным. Вы можете поиграть с точными числами, чтобы найти правильное значение для оборудования, но 16x1 или 32x1, вероятно, будут работать. Там нет никакой гарантии в отношении порядка вызова, но элементы в рабочей группе, которые вписываются в волновой фронт, будут иметь тенденцию выполняться вместе. Так что это побудит его работать по-крупному, выполняя 0,0; 1,0; и т. д. одновременно.

Во-вторых, Пожалуйста, уменьшите объем используемого пространства. Игра жизни имеет ровно два состояния для клетки. Но вы используете тридцать два бита для хранения этих двух состояний. Даже если вы хотите избежать боли при выполнении серьезных битовых манипуляций, вы можете, по крайней мере, каждый байт uint быть отдельной ячейкой. Извлечение N-го байта из uint - довольно тривиальный процесс.

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

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

Неподготовленные данные помещают (0, 0) в байт 0, (1, 0) в байт 4 и (0, 1) в байт (4 *WIDTH). С помощью swizzling вы хотите поместить четыре байта 0,0; 1,0; 0,1 и 1,1 все в одной строке кэша. То есть (0, 1) находится в байте 8, а (1, 1) в байте 12. Таким образом, если вы выбираете (1, 1), вы гарантированно получите все 4 значения в одной строке кэша .

Для максимальной производительности вы можете поиграть с образцами изгиба.

И, кроме того, вы можете даже взмахнуть своим gl_InvocationID.Вместо того чтобы полагаться на 2D-характер вашей отправки для получения исходной позиции для вызова, вы можете сделать вашу отправку одномерной и вычислить xy позицию вызова с помощью матрицы Swizzle.Таким образом, вызов 0 будет (0, 0), вызов 1 будет (1, 0), вызов 2 будет (0, 1), вызов 3 будет (1, 1) и т. Д.

Если вы включите работу, чтобы получить наиболее оптимальное хранилище данных с быстрым движением, то каждая строка кэша может представлять блок данных 8x8.Это означает, что любой непрерывно выполняющейся группе вызовов потребуется только максимум 4 строки данных кэша (в углу из 4 блоков).Кроме того, это помогает решить проблему записи, поскольку вы можете строить данные с помощью атомарных операций для shared переменных и просто записывать значения в конце.Вы упорядочиваете все так, чтобы никаким двум вызовам из отдельных рабочих групп не приходилось записывать одно и то же значение.

Это сделало бы все в значительной степени независимым от выполнения GPU.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...