>> |
No.24733
Файл: max [mask].png -(33 KB, 802x356, max [mask].png)
>>24724
Да, не подумал, у меня исходные данные триггерят худший случай с полностью непредсказуемыми ветками. Для сравнения проверил на полностью предсказуемых (pred) и сделал безбранчевый вариант (mask).
Относительно друг друга они работают за время, относящееся как
(vector) 1 : (scalar-pred) 2,1 : (scalar-unpred) 10 : (mask-pred) 3 : (mask-unpred) 3 (неудивительно).
Ранее я считал, что переносы между флоатовыми и целочисленными регистрами жутко дорогие: так, в Java при домножении 8-битного цвета на альфу у меня получался выигрыш в 7–13 раз (https://ideone.com/vj13We), если делать это не прямым
float alpha;
for (each pixel) pixel8 = round(pixel8 * alpha); а, скажем,
int alpha10 = round(alpha * 1024); // 10 бит точности — эмпирическая цифра
for (each pixel) pixel8 = (pixel8 * alpha10 + 512) >> 10; — в целых числах.
У @oldnewthing были статьи про SSE-трюки, где он прожужжал все уши domain crossing penalty:
>There are a few ways to load constants into SSE registers.
> • Load them from memory.
> • Load them from general purpose registers via movd.
> • Insert selected bits from general purpose registers via pinsr[b|w|d|q].
> • Try to calculate them in clever ways.
>Loading constants from memory incurs memory access penalties. Loading or inserting them from general purpose registers incurs cross-domain penalties. So let’s see what we can do with clever calculations.
Я даже думаю (так, примерно почувствовал), что в случае с альфой это связано не столько с domain crossing, сколько с латентностями флоатовых вычислений, которые вылезают при попытке сделать с результатом что-то интересное, вроде пересылки в целочисленный регистр. С бранчами вместо арифметики это не проявилось, но в целом касты, в т. ч. реинтерпрет_касты, между интами и флоатами без явной необходимости мне видятся такой себе идеей.
Кроме того.
Хотя для замера я расписал максимумы упрощённо, моя настоящая реализация соответствует определённой в IEEE 754 функции maxNum: если один из аргументов — NaN, она возвращает другой. Не так страшно, как звучит: если наивный максимум — это
if a > b then result := a else result := b; то IEEE-комплиантный — его небольшая модификация:
if (a > b) or (b <> b) then result := a else result := b; SSE этого, может быть, ради экономии транзисторов в 2000 году, не предусматривает (или же она детерминированно, если один из аргументов — NaN, возвращает не другой, а строго b), и я не уверен, можно ли полагаться на это в шейдерах, но это очень удобное свойство, и вот почему.
Мы постоянно работаем с величинами, которые можно рассматривать как веса эффектов. Тот же вклад диффузного освещения выражается косинусом угла между нормалью и направлением на источник света, иными словами, их скалярным произведением. Но в неосвещённом полупространстве косинус становится отрицательным, а вклад должен быть нулевым, поэтому окончательная формула принимает вид
diffuse = max(0.0, dot(normal, vec_to_light)); В более сложных случаях, той же модели Кука-Торренса, у нас появляется целая пачка таких промежуточных коэффициентов, отрицательность которых нам в этот раз даже и не страшна сама по себе, но они возводятся в степени, и принципиальное отличие между нулевым и отрицательным флоатами в том, что ряд функций, в первую очередь собственно pow(x, p), определены как возвращающие NaN для отрицательного x. По этой причине автор статьи https://habr.com/ru/post/424453/ (оригинал: https://learnopengl.com/PBR/Lighting) постоянно параноит отрицательность через max(0.0, value), тогда как с IEEE-комплиантным max() этого не нужно делать (по крайней мере в данном случае) — достаточно оставить финальный max(0.0, specular), который сделает из пропагейтнувшегося NaN’а тот же 0. В другой раз у нас может быть перемножение отрицательных чисел, которое вернёт не NaN, а неверный положительный результат, но в целом ряде случаев этого нет и можно спокойно убрать кучу паранойи.
Так вот, скалярный IEEE-комплиантный вариант, дописанный в ряд выше, показывает себя следующим образом:
(scalar-precise-pred) 2,2 : (scalar-precise-unpred) 11
то есть почти идентично наивным 2,1 : 10.
Таким же приёмом (добавлением b <> b) этого можно добиться и на SSE (https://stackoverflow.com/a/32332219). На FPC-интринсиках это выглядит как
function max(const a, b: Vec4f32): Vec4f32;
var
ma, mb: __m128;
begin
ma := _movups(@a);
mb := _movups(@b);
_movups(@result, _blendvps(_maxps(ma, mb), ma, _cmpps(mb, mb, {UNORD} 3)));
end; и даёт практически идентичную чистому maxps, взятому за 1, скорость
(vector-precise) 1,05.
Аналогично — uint32((a.x > b.x) or (b.x <> b.x)) и т. д. — можно добавить поддержку NaN и маскам, но они начинают совсем явно проигрывать UPD: а нет, гениальный компилятор вычисляет это выражение через джампы, так что я переделал на форсированно побитовое uint32(a.x > b.x) or uint32(b.x <> b.x) и получился как бы не такой уж плохой результат:
(mask-precise) 6,
но меня всё ещё настораживает двукратная разница относительно этого же способа без NaN.
|