[Перевод] Ветвление на GPU: так ли всё страшно?
Если вы читали в Интернете о ветвлении в GPU, то можете думать, что оно открывает врата Ада и впускает в этот мир демонов. В статьях говорится, что его нужно не допускать любой ценой, и что его можно избегать при помощи тернарного оператора, step () и других глупых математических трюков. Большинство таких советов в лучшем случае является устаревшим, а то и откровенно ошибочным.
Давайте исправим ситуацию.
При реализации ветвления в GPU нужно учитывать множество аспектов.
Первый — это тип данных, с которым выполняется ветвление. Допустим, если это данные из буфера констант, то ветвление будет очень малозатратным, ведь компилятор знает, что ветвление будет проходить один и тот же путь для каждого пикселя или вершины, потому что состояние буфера констант гарантированно остаётся постоянным для каждого обрабатываемого параллельно пикселя или вершины.
Однако если данные динамические, допустим, значение из текстуры, тогда каждый обрабатываемый параллельно пиксель должен обработать это ветвление. На этом этапе у компилятора есть выбор — он может решить, что код по каждому из путей достаточно короток и просто выполнить обе ветви и выбрать подходящий результат, или решить, что нужна правильная ветвь. Однако когда пиксели начинают так расходиться, для этих пикселей могут поломаться другие оптимизации. В общем случае следует избегать ветвления для шумных сигналов, изменяющих каждый пиксель.
Второй аспект — это тип кода в каждой из ветвей. Если вы собираетесь сэмплировать текстуры внутри ветви, то нужно будет использовать сэмплеры градиента или LOD. По сути, вам нужно писать код следующим образом:
float2 dx = ddx(uv);
float2 dy = ddy(uv);
UNITY_BRANCH // this is a Unity macro that forces a branch
if (_SomeConstant > 1)
{
o.Albedo += SAMPLE_TEXTURE2D_GRAD(_Albedo, sampler_Albedo, uv, dx, dy);
}
else
{
o.Albedo += SAMPLE_TEXTURE2D_GRAD(_Albedo2, sampler_Albedo2, uv, dx, dy);
}
Обратите внимание на макрос UNITY_BRANCH — он компилируется в специфичную команду API, вынуждающую GPU (которые её поддерживают) выполнить реальное ветвление, а не, допустим, сэмплировать обе текстуры и выбрать нужный результат.
Зачем это нужно? Чтобы сохранить выполняемую GPU оптимизацию четырёхугольников 2×2. Если этого не сделать, то на большинстве платформ мы получим ошибку компиляции, на других он выполнит обе части, а на третьих это поломает оптимизацию четырёхугольников 2×2 и код будет выполняться во много раз медленнее. Получив используемые для сэмплирования текстур производные до ветвления и передавая их, мы предотвращаем потенциальное расхождение этих пикселей.
В-третьих, нам нужно учесть, как будет вычисляться ветвление. Вплоть до самой последней версии HLSL, которая пока недоступна в Unity, в следующем коде, в отличие от ситуации на ЦП, если _Constant равна 0, то функция SomeFunc () всё равно будет вызываться и вычисляться.
if (_Constant > 1 && SomeFunc() > 1)
{
}
Во всех моих шейдерах из asset store вы найдёте такой код:
#if _BRANCHSAMPLES
#if _DEBUG_BRANCHCOUNT_TOTAL
float _branchWeightCount;
#define MSBRANCH(w) if (w > 0) _branchWeightCount++; if (w > 0)
#else
#define MSBRANCH(w) UNITY_BRANCH if (w > 0)
#endif
#else
#if _DEBUG_BRANCHCOUNT_TOTAL
float _branchWeightCount;
#define MSBRANCH(w) if (w > 0) _branchWeightCount++;
#else
#define MSBRANCH(w)
#endif
#endif
Этот код с лёгкостью позволяет нам подсчитать, какое количество ветвлений выполняется для конкретного пикселя, и переключать используемые ветвления if; также он имеет режим просмотра, отображающий количество ветвлений для каждого пикселя. Основная задача этой функциональности заключается в возможности визуализации частоты ветвлений, выполняемых на потенциально расходящихся путях. Я часто буду создавать её и для отдельных фич (трипланарное, стохастическое ветвление, и т. п.).
Ещё один набор макросов позволяет нам подсчитать истинное количество сэмплов текстур в каждом пикселей, чтобы мы могли легко визуализировать сэмплы:
#if _DEBUG_SAMPLECOUNT
int _sampleCount;
#define COUNTSAMPLE { _sampleCount++; }
#else
#define COUNTSAMPLE
#endif
Используется он таким образом:
half4 a0 = half4(0,0,0,0);
half4 a1 = half4(0,0,0,0);
half4 a2 = half4(0,0,0,0);
MSBRANCH(tc.pN0.x)
{
a0 = MICROSPLAT_SAMPLE_DIFFUSE(tc.uv0[0], config.cluster0, d0);
COUNTSAMPLE
}
MSBRANCH(tc.pN0.y)
{
a1 = MICROSPLAT_SAMPLE_DIFFUSE(tc.uv0[1], config.cluster0, d1);
COUNTSAMPLE
}
MSBRANCH(tc.pN0.z)
{
a2 = MICROSPLAT_SAMPLE_DIFFUSE(tc.uv0[2], config.cluster0, d2);
COUNTSAMPLE
}
Для вывода данных на экран мы можем сделать так:
#if _DEBUG_BRANCHCOUNT
o.Albedo = (float)_branchWeightCount / 12.0f;
#endif
Визуализация трипланарного ветвления в MicroSplat. Чем светлее области, тем больше ветвлений.
Для визуализации сэмплов я добавил пороговое свойство, которое может задавать пользователь — количество сэмплов выше порогового значения отрисовываются жёлтым, а всё, что ниже отрисовывается в оттенках красного:
#if _DEBUG_SAMPLECOUNT
float sdisp = (float)_sampleCount / max(_SampleCountDiv, 1);
half3 sdcolor = float3(sdisp, sdisp > 1 ? 1 : 0, 0);
o.Albedo = sdcolor;
#endif
Визуализация того же количества в MicroSplat. Области красного создают меньше 9 сэмплов, а жёлтые — от 9 сэмплов и больше. Если ветвление отключено, шейдер получает 28 сэмплов на пиксель, поэтому в этом простом случае мы при помощи ветвления экономим 2/3 затрат на сэмплирование.
Ещё один пример, на этот раз с трипланарными, стохастическими кластерами текстур, включено ветвление весов рельефа. Обратите внимание, что сначала система выполняет усечение по весу текстуры, трипланарные и стохастические проверки тоже усекаются.
Готовый рендер
Визуализация ветвлений. Обратите внимание. что частота ветвления намного выше и создаёт больше расходящихся пикселей, но области всё равно достаточно велики, чтобы в конечном итоге обеспечивать выигрыш.
Визуализация количества сэмплов. Без ветвлений шейдер получает 100 сэмплов на пиксель. Со включенными ветвлениями в областях красного цвета меньше 34 сэмплов на пиксель, а в жёлтых областях их от 34 и больше. Количество сэмплов на пиксель находится в интервале от 9 до 72.
Благодаря таким методикам можно чётко визуализировать то, что делает GPU, и усекать значительную часть объёма передаваемой информации в сложном шейдере. Кроме того, можно легко включать и отключать ветвление в различных фичах или просматривать их данные по отдельности, чтобы убедиться, что оптимизации на самом деле оптимизируют выполнение.
- Не бойтесь ветвления в GPU, в большинстве случаев оно совершенно приемлемо
- Знайте, что выбирается при ветвлении, а что обходится
- Избегайте ветвлений с высокочастотными данными и создания расходящихся пикселей
- С особым вниманием следует относиться к ветвлениям с обходом текстур
- Визуализируйте данные, чтобы чётко видеть, что делает GPU
- По возможности выстраивайте ветвление в порядке от ветвей с наиболее вероятным усечением до ветвей с наименее вероятным усечением.