Я хочу выполнить восемь параллельных добавлений 16-битных значений с помощью AVX SIMD. Требуется сложение с переполнением, т. е. «добавление с переносом», как это делается со старой мнемоникой «adc» x86.
Я реализовал 50 процентов решения AVX самостоятельно, обработка переноса, также выполняемая инструкциями AVX, отсутствует. Мое текущее решение:
typedef union _uint16vec uint16vec, *uint16vec_ptr;
union __attribute__((aligned(16))) _uint16vec
{
__m128i x;
uint16_t y[8];
};
__m128i parallel_add_with_carry ( __m128i n1, __m128i n2 )
{
volatile uint16vec res, stupid_carry;
uint32_t i;
stupid_carry.x = n1; /* load 8x uint16_t for carry adjustment below */
__asm__
(
"movdqa %1, %%xmm0 \n\t"
"movdqa %2, %%xmm1 \n\t"
"vpaddw %%xmm0, %%xmm1, %%xmm0 \n\t"
"movdqa %%xmm0, %0 \n\t"
: "=m" (res.x) /* output */
: "m" (n1), "m" (n2) /* inputs */
: "xmm0", "xmm1" /* GCC, please clobber XMM0 and XMM1 */
);
/* if each of the eight uint16_t in the result is lesser than
* the previous value, then we have the overflow situation...
*/
for (i=0;i<8;i++)
res.y[i] += (res.y[i] < stupid_carry.y[i]) ? 1 : 0;
return res.x;
}
void test ( void )
{
uint16vec v1 = {0}, v2 = {0}, res;
v1.y[0] = 0x000A; v2.y[0] = 0x0014; /* 10+20 = 30 (0x001E), no overflow */
v1.y[1] = 0xFFF0; v2.y[1] = 0x0013; /* 0xFFF0 + 0x0013 = 0x0003 -> overflow -> 0x0004 */
res.x = parallel_add_with_carry(v1.x, v2.x);
fprintf(stdout,"%04X | %04X\n", res.y[0], res.y[1]);
}
Объектный код эпилога функции, созданный GCC, ужасен (даже с -O3). Мой вопрос: есть ли лучшее решение с поддержкой AVX для проблемы «добавления с переносом»?
Моя идея заключалась в том, чтобы предоставить 128-битный вектор { 0x0001,0x0001,...,0x0001} в качестве 128-битной временной переменной, добавив это (вектор переноса) к восьми uint16_t тогда и только тогда, когда предыдущая операция сравнения привела к результату «меньше чем» для конкретного uint16_t в 128-битном векторе.
Я просмотрел документацию Intel и нашел хорошие инструкции по добавлению, которые просто копируют исходные части вектора, если условие выполнено.
Поддержка этой «штуки AVX» высоко ценится. Спасибо.
Почему встроенный ассемблер? То, как вы это пишете, с операндами ввода и вывода в память, неэффективно. Инструкции SIMD x86 допускают использование операнда-источника памяти. И вы не получите никакой выгоды от использования устаревшей кодировки SSE movdqa; если вы хотите сохранить размер кода, избегая кодировки VEX, используйте movaps. Но, вероятно, лучше использовать vmovdqa, хотя при использовании 128-битных векторов вы не получите зависаний при переходе SSE/AVX, пока верхние половины регистров YMM/ZMM чистые (vzeroupper)
Ваш существующий код выглядит так, как будто он увеличивает элемент res на 1, если было выполнение. Это очень странно; у переноса есть позиционное значение 2^16, но вы возвращаете его обратно в нижнюю часть того же элемента, позиционное значение 2^0. Обычно вам нужно условно увеличить другой вектор, если вы используете восемь параллельных 32-битных счетчиков, каждый из которых разделен на две 16-битные половины.
Питер, большое спасибо за ваш вклад. Я собираюсь пересмотреть этот материал. Основная идея заключалась в параллельном выполнении восьми 16-битных сложений. Затем, сравнивая с промежуточным результатом до текущего сложения, выясняем, какое из восьми 16-битных значений имело переполнение, исправляя только эти 16-битные значения, добавляя еще одно.
И да, я хотел бы использовать все виды AVX/AVX-512, чтобы оптимизировать это. Этот код используется при вычислении контрольной суммы исполняемых файлов PE, которые требуют сложения всех 16-битных слов (с переносом). Текущая версия хорошо справляется со своей задачей, но все еще неоптимальна...
С этой 16-битной контрольной суммой (подача переноса обратно в перенос) остается ли сложение ассоциативным? Я так думаю, иначе вы бы вообще не смогли векторизовать это. Вы можете сократить задержку критического пути, добавив перенос в другой вектор, добавляя его обратно в основной аккумулятор только в конце.
Вы имеете в виду, что у вас есть все доступные версии AVX1/2/512, и вам нужна одна версия, которая будет максимально быстрой, независимо от того, означает ли это использование AVX2 или AVX-512? Или вы имеете в виду, что вам также нужна версия, использующая только AVX2, и еще одна версия, использующая только AVX1, а также версия, использующая AVX-512BW?
@Devvy Обратите внимание, что в вашем коде вы неправильно распространяете перенос, поскольку добавление переноса также может привести к переполнению, и это переполнение необходимо учитывать в более высоких элементах.
@AndreySemachev: Двойное переполнение невозможно. Рассмотрим 8-битный код с максимально возможными входными данными: 0xFF + 0xFF — это 0xFE с переносом 1. Добавляя его обратно внизу, мы получаем 0xFF без переполнения. Это контрольная сумма, а не сложение bigint, поэтому распространение переноса не происходит в другой элемент.
@PeterCordes Первоначально я думал, что ОП хочет реализовать обычное сложение с переносом, что означает, что перенос распространяется на следующий, более значимый элемент суммы. Да, без распространения двойное переполнение невозможно.





Одна простая идея — использовать беззнаковое насыщение, которое делает результаты сложения отличными от сложения с переполнением, когда происходит переполнение.
__m128i parallel_add_with_carry(__m128i n1, __m128i n2)
{
// Make an all-ones vector. This is virtually free and does not
// consume an execution unit.
const __m128i mm_all_ones = _mm_cmpeq_epi32(n1, n1);
__m128i mm_sum = _mm_add_epi16(n1, n2);
// Since there is no "not equal" comparison that produces
// a vector mask, we compare for "equal" and get a negated
// carry mask.
__m128i mm_carry = _mm_cmpeq_epi16(_mm_adds_epu16(n1, n2), mm_sum);
// Negate the mask, which turns elements that were 0 to -1 and vise versa
mm_carry = _mm_xor_si128(mm_carry, mm_all_ones);
// Add carry (i.e. substract -1 or 0)
mm_sum = _mm_sub_epi16(mm_sum, mm_carry);
return mm_sum;
}
Эта реализация совместима с SSE2 и более поздними версиями. Использование AVX-512 и регистров opmask возможно, но вряд ли это будет быстрее. Вот некоторые оценки (asm-код, сгенерированный gcc 14.2 с помощью -O3 -march=rocketlake):
Если вы обрабатываете умеренные объемы данных, которые, скорее всего, поместятся в кеш, возможно, будет полезно дополнительно оптимизировать этот код, развернув цикл и используя отдельные аккумуляторы для суммирования и переноса, и складывая их вместе только в конце, когда все данные обрабатывается. Это улучшает параллелизм на уровне команд (ILP), поскольку отдельные накопления образуют отдельные цепочки зависимостей и могут происходить параллельно, при условии, что в ЦП достаточно вычислительных блоков и алгоритм не ограничен где-либо еще (например, пропускной способностью памяти).
__m128i parallel_add_with_carry_loop(const unsigned char* data, size_t size)
{
__m128i mm_sum1 = _mm_setzero_si128(), mm_sum2 = _mm_setzero_si128();
__m128i mm_carry1 = _mm_setzero_si128(), mm_carry2 = _mm_setzero_si128();
const __m128i mm_all_ones = _mm_cmpeq_epi32(mm_sum1, mm_sum1);
for (size_t i = 0u, n = size & (size_t)(-32); i < n; i += 32)
{
__m128i mm1 = _mm_loadu_si128((const __m128i*)(data + i));
__m128i mm2 = _mm_loadu_si128((const __m128i*)(data + i + 16));
__m128i mm_new_sum1 = _mm_add_epi16(mm_sum1, mm1);
__m128i mm_new_sum2 = _mm_add_epi16(mm_sum2, mm2);
__m128i mm_neg_carry1 = _mm_cmpeq_epi16(
_mm_adds_epu16(mm_sum1, mm1), mm_new_sum1);
__m128i mm_neg_carry2 = _mm_cmpeq_epi16(
_mm_adds_epu16(mm_sum2, mm2), mm_new_sum2);
mm_sum1 = mm_new_sum1;
mm_sum2 = mm_new_sum2;
mm_carry1 = _mm_sub_epi16(mm_carry1,
_mm_xor_si128(mm_neg_carry1, mm_all_ones));
mm_carry2 = _mm_sub_epi16(mm_carry2,
_mm_xor_si128(mm_neg_carry2, mm_all_ones));
}
// If size is not a multiple of 32, process the tail bytes here
// ...
// Combine the accumulated sum and carry. Note that adding sums
// may overflow, and we need to account the carry from this addition.
mm_sum1 = parallel_add_with_carry(mm_sum1, mm_sum2);
mm_carry1 = _mm_add_epi16(mm_carry1, mm_carry2);
mm_sum1 = _mm_add_epi16(mm_sum1, mm_carry1);
return mm_sum1;
}
Так как это для контрольной суммы PE (согласно комментариям), то сдвига влево быть не должно, предполагается добавление переноса элемента к этому самому элементу (и тогда цикл тоже не нужен, так как конец -вокруг керри не может генерировать другой керри)
@ user555045: И вы можете добавить в отдельный векторный аккумулятор для переносов, я думаю, это сократит задержку критического пути при использовании этого в цикле по сравнению с более длинной цепочкой операций для обновления одного вектора перед просмотром следующего фрагмента исходных данных. .
Если бы вы пытались реализовать 128-битное сложение, вы бы сделали это с помощью двух 64-битных элементов, просто безоговорочно вычитая 0 или -1 из старшей половины. AVX включает vpcmpgtq, чтобы вы могли обойти недостаток насыщенной математики для элементов других размеров. Конечно, SIMD в этом плане не лучше скалярного на x86-64; add/adc более эффективен, чем махинации с AVX, если только вы не можете использовать хранилище частичных слов для своих чисел, чтобы избежать распространения переноса в большинстве операций, перенормируя только время от времени. (Могут ли подпрограммы с длинными целыми числами выиграть от SSE?)
@user555045 user555045 Тогда я неправильно понял вопрос. Спасибо, что указали на это.
Я обновил ответ, чтобы реализовать контрольную сумму PE, а не добавлять с переносом.
Можем ли мы избежать _mm_andnot_si128 и просто использовать _mm_sub_epi16? Или невозможно напрямую получить 0 или -1, которые нам нужны, из pcmpeqw или pcmpgtw, полученных с помощью одной предыдущей инструкции?
@PeterCordes Да, я применил ваше предложение об использовании _mm_sub_epi16. Спасибо. Но я не думаю, что будет какая-либо значительная разница в производительности, если судить по симуляции uica.uops.info. Это сокращает код на 2 мопса и пару инструкций, вот и все. Разница лишь в том, что константу «все единицы» сгенерировать проще, чем вектор из 16-битных. Вам все равно нужно отменить маску переноса, поэтому вы меняете pandn на pxor, что эквивалентно.
Если делать это для нескольких векторов, мы могли бы накапливать инвертированные переносы, поэтому нам нужно будет инвертировать их только один раз. (Или sub их из основного векторного аккумулятора вместо add). Позже выносы будут происходить в разных точках, но итог все равно x + (x/65536) Я думаю. Невозможно использовать в API функции-обертки OP, которая делает все для одного вектора, но автономная функция отвлекает от оптимизации цикла, который ее встраивает.
@PeterCordes Я не уверен, как вы будете накапливать отрицательный перенос. Вам необходимо накопить отрицательные элементы переноса, которые являются нулями, то есть вам нужно сделать эти элементы ненулевыми перед тем, как накапливать так или иначе. На данном этапе имеет ли значение, накапливаете ли вы их отдельно или сразу в конечный результат? Использование отдельного аккумулятора потенциально лучше ILP, но я не уверен, что это будет иметь значение на практике.
Я думал pcmpeqw/psubw carries, cmp_result, а затем добавить или заменить переносы один раз в конце. Так что вам не нужно отрицание для каждого pcmp. Это работает? Я не до конца прошел и не опробовал это.
И да, улучшение ILP от отдельного аккумулятора должно иметь большое значение; Задержка критического пути в 4 цикла для вашей версии (параллельные add и adds) по сравнению с задержкой критического пути в 1 цикл, поэтому просто ограничиваем 3 или 4 векторных ALU-операции за такт (при условии, что внешний интерфейс достаточно широк, чтобы выдерживать нагрузки + накладные расходы цикла) , то есть 5/3 цикла на вектор меньше с 4, для достаточно больших входных блоков, которые неупорядоченный exec не может сильно перекрывать их с окружающим кодом, но где пропускная способность памяти не является узким местом, так что, возможно, уже жарко в L2 кэш.
@PeterCordes Я не думаю, что это работает, если я правильно понимаю. Опять же, вам нужно увеличить результат, если произошло переполнение (т. е. отрицательный результат переноса/pcmpeqw равен нулю). Вы не должны изменять сумму, если не было переполнения. Например, n1 = 0xFFFF, n2 = 1, промежуточная сумма = n1 + n2 = 0, отрицательный перенос = 0, окончательная сумма = сумма + (отрицательный перенос == 0) = 0 + 1 = 1. Пример 2: n1 = 1 , n2 = 1, промежуточная сумма = 2, отрицательный перенос = 0xFFFF, окончательная сумма = 2.
@PeterCordes Я добавил в ответ версию с отдельными аккумуляторами. Ограничивающая задержка в этом случае — это накопление переноса, которое должно быть таким же, как задержка версии без отдельного аккумулятора переноса. Т.е. вам все равно нужно вычислить mm_new_sum, чтобы накопить перенос. Это означает, что весь цикл + последнее сложение (сумма, перенос) должно занять примерно то же время, что и цикл с одним аккумулятором. Я что-то упускаю?
@PeterCordes Одна из оптимизаций, которая может быть полезна, - это развернуть цикл и использовать несколько аккумуляторов суммы и переноса. Это разорвало бы цепочку зависимостей для каждого из аккумуляторов. Но тогда нет необходимости накапливать сумму и перенос отдельно - с таким же успехом можно развернуть одноаккумуляторный вариант.
Параллельная версия имеет две отдельные зависимости, переносимые в цикле, по 1 такту, одна из которых питает другую цепочкой длиной в несколько тактов. Таким образом, один должен опережать другого на пару тактов, но, что особенно важно, carry не имеет обратной связи с sum, поэтому пропускная способность все равно может составлять 1/такт (или ограничиваться пропускной способностью ALU). Ограничения задержки и пропускной способности процессоров для операций, которые должны выполняться последовательно обсуждается более простой пример с двумя цепочками операций, одна из которых связана с другой.
@PeterCordes Даже если мы не будем учитывать накопление задержки mm_sum (при условии, что аккумулятор опережает mm_carry), у вас все равно есть _mm_adds_epu16+_mm_cmpeq_epi16+_mm_xor_si128+_mm_sub_epi16 = 4 цикла задержки для расчета mm_carry на каждой итерации цикла. С одним аккумулятором mm_sum у вас будет то же самое плюс _mm_add_epi16, то есть 5 циклов. Однако, напр. при 2x развертывании вы создаете вторую цепочку зависимостей и вдвое уменьшаете общую задержку цикла для каждой из цепочек. Это гораздо больший выигрыш, чем 5->4 цикла на итерацию.
@AndreySemachev следующая итерация может начаться до того, как закончится цепочка cmp/xor/sub, add/cmp/xor следующей итерации не зависит от результата подпрограммы
@AndreySemachev: Вы прочитали ответ по ссылке в моем последнем комментарии? Он объясняет то, что обобщил пользователь 555.... (и показывает диаграмму в формате ASCII), что exec, находящийся вне порядка, может начать работу раньше на следующей итерации одной цепочки, поэтому им не нужно работать синхронно.
Огромное спасибо за такую прекрасную работу и сотрудничество. Ребята, вы потрясающие! Многим людям может быть полезна эта работа, а также выполнение других задач SIMD. Лично я не совсем разбирался в этой области SIMD, но многому научился у вас. Еще раз спасибо.
Еще один «глупый» вопрос: какой бит CPUID мне нужно проверить на предмет SSE2?
@Devvy На 64-битной версии x86 вам не нужно ничего проверять, поскольку SSE2 неявно присутствует на любом 64-битном процессоре x86. На старых 32-битных процессорах вы можете проверить бит 26 edx, возвращаемый cpuid, с eax=1. См. Определить поддержку процессора для SSE2?.
@PeterCordes Да, я читал ответ, но не осознавал, что _mm_adds_epu16, _mm_cmpeq_epi16 и _mm_xor_si128 не зависят от предыдущей итерации цикла накопления переноса и могут быть выполнены раньше. Комментарий пользователя user555045 вызвал этот щелчок. Спасибо вам обоим.
Я нашел решение, используя инструкции AVX-512. Решением является функция маскировки слиянием с использованием регистров маски ЦП (с именами от K0 до K7).
Я тестировал его на серверном сервере с процессором AMD Epyc серии 7000 (48 ядер).
В контексте моего первоначального исходного кода:
__m128i parallel_add_with_carry_simd ( __m128i n1, __m128i n2 )
{
static const uint16vec carry = { .y[0] = 0x0001,
.y[1] = 0x0001,
.y[2] = 0x0001,
.y[3] = 0x0001,
.y[4] = 0x0001,
.y[5] = 0x0001,
.y[6] = 0x0001,
.y[7] = 0x0001 };
uint16vec res;
__asm__
(
"movdqa %1 , %%xmm0 \n\t"
"movdqa %2 , %%xmm1 \n\t"
"movdqa %3 , %%xmm3 \n\t"
"movdqa %%xmm0, %%xmm2 \n\t"
"vpaddw %%xmm1, %%xmm0, %%xmm0 \n\t"
"vpcmpltuw %%xmm2, %%xmm0, %%k1 \n\t"
"vpaddw %%xmm3, %%xmm0, %%xmm0 %{%%k1%}\n\t"
"movdqa %%xmm0, %0 \n\t"
: "=m" (res.x)
: "m" (n1), "m" (n2), "m" (carry.x)
: "xmm0", "xmm1", "xmm2", "xmm3"
);
return res.x;
}
По сути, вам нужно выполнить последовательность:
Это реализует старую мнемонику x86 «добавление с переносом» «adc» для векторов 16-битных целых чисел без знака.
Встроенная сборка загружает два добавляемых вектора с переносом n1 и n2 в XMM0 и XMM1. Вспомогательный вектор переноса (1,1,1,1,1,1,1,1) загружается в XMM3.
Сохраняем исходный вектор n1 в XMM2 (для последующего сравнения). Расчет n1 + n2 производится (без насыщения!) по формуле:
XMM0 := XMM0 (n1) + XMM1 (n2)
Этот промежуточный результат еще не включает переносы.
Главное — сравнение
vpcmpltuw %%xmm2, %%xmm0, %%k1
что означает: сравнить промежуточный результат XMM0 с исходным входным значением n1 в XMM2. Используйте код условия «меньше чем» (LT). Сохраните восемь результатов сравнения как отдельные биты в регистре маски K1.
Вторая инструкция vpaddw выполняет работу по «коррекции переноса» восьми 16-битных целых чисел в XMM0:
vpaddw %%xmm3, %%xmm0, %%xmm0 %{%%k1%}
Это (странное) обозначение означает: добавить вектор переноса (в XMM3) к промежуточному результату (в XMM0), снова сохранив общий результат в XMM0. При добавлении используйте режим слияния-маскирования (в отличие от обнуления-маскирования - см. ниже). Псевдокод маскировки слияния:
FORALL eight 16bit integers in XMM0 DO
IF : bit in K1 is 1
THEN : add 0x0001 from the carry vector in XMM3 (XMM0 := XMM0 + XMM3)
ELSE : just copy the current 16bit integer from src (XMM0) to dst (XMM0)
Альтернативой операции слияния-маскирования является обнуление-маскирование. В этом случае вам придется указать встроенную сборку следующим образом:
vpaddw %%xmm3, %%xmm0, %%xmm0 %{%%k1%}%{z%}
Если бит в регистре К1 равен единице, то производится сложение. Если это нулевой бит, то в месте назначения сохраняется 0x0000 (таким образом обнуляется текущее 16-битное целое число).
В конце концов, я смог решить эту проблему благодаря этому посту: stackoverflow, маска записи в AVX-512
Вроде бы это сработало бы, но выглядит неэффективно. После того, как это будет встроено в цикл, который вычисляет общую контрольную сумму, все равно будут некоторые избыточные ходы (если вы использовали встроенные функции вместо встроенного ассемблера, компилятор мог бы что-то с этим сделать, но не со встроенным ассемблером). Также кажется возможным сделать это с меньшей задержкой через цепочку зависимостей, переносимую циклом (все еще речь идет о цикле, который здесь не показан, но вам придется вызывать эту функцию из цикла).
Спасибо за ваш вклад. Конечно, я интегрировал это во встроенную секцию ассемблера, которая выполняет цикл как часть it (метка перехода выровнена по 16 байтам), все чередуется настолько, насколько мне это удается — честно говоря, я не знаю, так ли это Материалы о трубопроводах u/v, созданные 20 лет назад, все еще играют роль? Код работает «как шарм», по крайней мере, на AMD Epyc 7000er.
Проблема с u/v осталась далеко позади, теперь у нас есть внеочередное выполнение, поэтому точное чередование не так важно, как раньше. Я полагаю, что выполнение всего цикла во встроенном ассемблере — это нормально. Тем не менее, я думаю, что последовательность vpaddw/vpcmpltuw/vpaddw имеет задержку в 6 циклов (или, может быть, 5?) в Zen4, поскольку результат привязан обратно к входным данным, что ограничивает выполнение цикла с частотой 1 итерация на 6 циклов, но кажется возможным сделать это быстрее, используя идею Питера Кордеса добавить переносы отдельно (что не связывает вычисления одинаковым образом) и объединить две отдельные суммы после цикла
Спасибо за разъяснения по поводу н/в. Я довольно старый разработчик, 25 лет назад писал ассемблерный код x86 (например, написал расширитель DOS - боже мой, это неловко... :-))
vpcmpuwэто AVX-512. В AVX2 есть только сравнение целых чисел со знаком, хотя у него есть мин/макс без знака и насыщающее вычитание, с которыми вы могли бы что-то сделать. Вы отметили этот AVX2. Вы также ищете его версию, поддерживающую x86-64-v3 (AVX2, но не AVX-512)?