
Большинство SIMD инструкций узконаправленны, например применяют бинарную операцию параллельно для нескольких чисел, упакованных в длинный регистр. Применение таких операций прямолинейно и в большинстве случаев компилятор сам оптимизирует код с использованием таких инструкций. Например компилятор легко соптимизирует таким образом проверку несложного предиката на массиве или например суммирование элементов массива. Есть однако и более универсальные инструкции, в частности довольно много всякого рода манипуляций с битами внутри регистра. В этой статье хочу рассказать о двух таких инструкциях: уже давно присутствующей PSHUFB и довольно новой GF2P8AFFINEQB, расскажу как с их помощью делать побайтовую обработку общего вида и приведу пару примеров с известными операциями такими как popcount, подсчет четности, разворот битов числа.
Параллельный просмотр таблицы с помощью PSHUFB
Давайте для начала познакомимся с самой инструкцией, нам нужна её 128-битная версия без маскирования, согласно документации интел (зеркало на гитхаб)
Synopsis
__m128i _mm_shuffle_epi8 (__m128i a, __m128i b) #include <tmmintrin.h> Instruction: pshufb xmm, xmm CPUID Flags: SSSE3
Description
Shuffle packed 8-bit integers in a according to shuffle control mask in the corresponding 8-bit element of b, and store the results in dst.
Operation
FOR j := 0 to 15 i := j*8 IF b[i+7] == 1 dst[i+7:i] := 0 ELSE index[3:0] := b[i+3:i] dst[i+7:i] := a[index*8+7:index*8] FI ENDFOR
Latency and Throughput
Architecture | Latency | Throughput (CPI) |
Alderlake | 1 | 0.5 |
Icelake Xeon | 1 | 0.5 |
Sapphire Rapids | 1 | 0.5 |
Skylake | 1 | 1 |
Итак, доступна начиная с SSSE3, легковесна и делает "перемешивание 8-битных чисел из a в соответствии с маской b". Как-то не очень понятно, но если присмотреться к строкам 6-7 описании операции, то становится понятным, что это просмотр таблицы. Итак, индекс iпробегает все значения кратные 8, в index записывается первые 4 бита b в позиции i, после чего в результат записывается 8 бит из a начиная с 8*index. Вот неоптимизированный эквивалент инструкции
std::array<uint8_t, 16> _mm_shuffle_epi8(const std::array<uint8_t, 16>& a, const std::array<uint8_t, 16>& b) { std::array<uint8_t, 16> result; for (size_t i = 0; i < 16; ++i) { if (b[i] & (1 << 7)) { result[i] = 0; } else { result[i] = a[b[i] & 15]; } } return result; }
По сути это параллельный просмотр таблицы размера 16. Интересным тут является то, что в качестве индексов берутся только 4 бита. Есть всего 16 возможных значений, которые могут принимать 4 бита и, соответственно, все эти значения умещаются в 128-регистр -- так по сути эта инструкция и работает. Интересно то, что таким образом мы можем представить любую операцию над 4-мя битами. В некоторых случаях этого достаточно чтобы сделать и более сложное вычисление например над байтами. Вот пример как можно сделать подсчет побайтового попкаунта
// Таблица popcount для 4 бит const __m128i lookup_popcount_4 = _mm_setr_epi8( 0, 1, 1, 2, // 0000, 0001, 0010, 0011 1, 2, 2, 3, // 0100, 0101, 0110, 0111 1, 2, 2, 3, // 1000, 1001, 1010, 1011 2, 3, 3, 4 // 1100, 1101, 1110, 1111 ); // Побайтовый popcnt void popcount_16x8(const uint8_t* x, uint8_t* result) { __m128i data = _mm_loadu_si128((__m128i const*)x); const __m128i low_bits_mask = _mm_set1_epi8(0x0F); // Popcount 4 нижних битов __m128i low_bits = _mm_and_si128(data, low_bits_mask); __m128i low_count = _mm_shuffle_epi8(lookup_popcount_4, low_bits); // Popcount 4 верхних битов __m128i high_bits = _mm_srli_epi16(data, 4); high_bits = _mm_and_si128(high_bits, low_bits_mask); __m128i high_count = _mm_shuffle_epi8(lookup_popcount_4, high_bits); __m128i result_vec = _mm_add_epi8(low_count, high_count); _mm_storeu_si128((__m128i*)result, result_vec); }
Отмечу, что для ARM-процессоров аналогом являются инструкции vqtbl1q_u8 и смежные.
А вот тут можно найти более сложный пример использования этого метода для ускорения векторных операций над полеми, насколько мне известно, используется для кодов Рида-Соломона в RAID6. А раз уж начали про
, то ….
Произвольные аффинные операции надс помощью GF2P8AFFINEQB
-- это поле размера 2, умножение в котором соответствует логическому "и", а сложение производится по модулю 2 (aka XOR). Относительно недавно Intel (а следом и AMD) пополнил линейку AVX-512 набором Galois Field New Instructions, основное назначение которого побайтовая обработка в поле
для чего-то в духе кодов Рида-Соломона или Rijndael энкрипции (хотя для этого выпустили отдельное расширение VAES). Две из трёх инструкций GFNI завязаны непосредственно на
, но последняя инструкция универсальна и работает над
, снова обращаемся к гайду от Intel.
Synopsis
__m512i _mm512_gf2p8affine_epi64_epi8 (__m512i x, __m512i A, int b) #include <immintrin.h> Instruction: vgf2p8affineqb zmm, zmm, zmm, imm8 CPUID Flags: GFNI + AVX512F
Description
Compute an affine transformation in the Galois Field 2^8. An affine transformation is defined by A * x + b, where A represents an 8 by 8 bit matrix, x represents an 8-bit vector, and b is a constant immediate byte. Store the packed 8-bit results in dst.
Operation
DEFINE parity(x) { t := 0 FOR i := 0 to 7 t := t XOR x.bit[i] ENDFOR RETURN t } DEFINE affine_byte(tsrc2qw, src1byte, imm8) { FOR i := 0 to 7 retbyte.bit[i] := parity(tsrc2qw.byte[7-i] AND src1byte) XOR imm8.bit[i] ENDFOR RETURN retbyte } FOR j := 0 TO 7 FOR i := 0 to 7 dst.qword[j].byte[i] := affine_byte(A.qword[j], x.qword[j].byte[i], b) ENDFOR ENDFOR dst[MAX:512] := 0
Latency and Throughput
Architecture | Latency | Throughput (CPI) |
Icelake Intel Core | - | 1 |
Icelake Xeon | 3 | 1 |
Sapphire Rapids | - | 1 |
Здесь описание посложнее, да еще и немного неточное, все-таки операции проводятся не в, а над матрицами в
, давайте разбираться: вот пример из гайда от Интел

Здесь приводится простой пример как аффинным преобразованием можно развернуть биты в каждом из байтов числа. GF2P8AFFINEQBумеет делать 2/4/8 таких операций при использовании 128/256/512-битных регистров соответственно, вот например как это выглядит для 256-битного регистра.

Фактически идея с разворотом была взята на вооружение в последних версиях Clang, посмотрим во что компилируется __builtin_bitreverse64:
#include <cstdint> uint64_t reverse_bits(uint64_t x) { return __builtin_bitreverse64(x); }
.LCPI0_1: .byte 1 .byte 2 .byte 4 .byte 8 .byte 16 .byte 32 .byte 64 .byte 128 reverse_bits(unsigned long): vmovq xmm0, rdi vgf2p8affineqb xmm0, xmm0, qword ptr [rip + .LCPI0_1]{1to2}, 0 vmovq rax, xmm0 bswap rax ret
Константа LCPI0_1соответствует левой матрицы с единичками на диагонали, применении аффинного преобразования в строчке 12 меняет порядок битов в каждом байте, а в строчке 14 разворачиваются байты, что в итоге даёт полный разворот в 64-битном числе. Вот краткий список похожих операций, которые можно реализовать через умножение на матрицу:
Побайтовая четность
Побайтовые сдвиги влево вправо
Побайтовые циклические сдвиги
В качестве более сложного примера приведу пример как сделать так, чтобы GF2P8AFFINEQB работала как другая инструкция GFNI GF2P8MULB, предназначенная именно для. Напомню вкратце, что элементы
- это многочлены степени меньше 8 над
c обычным сложением многочленов и умножением по модулю некоторого неприводимого многочлена степени 8. Эти многочлены можно трактовать и как элементы векторного пространства
, если мы зафиксируем элемент
, то умножение на
в
является линейной операцией в
, а значит должна существовать матрица
такая, что
Слева подразумевается умножение в, справа -- умножение матрицы на вектор в
. Собственно идея в том, чтобы для каждого элемента поля посчитать соответствующую матрицу. После этого мы можем сгруппировать умножение на
вплоть до 64 других элементов, что позволит например делать
, т.е. прибавить к одному вектору другой, помноженный на скаляр - типичная операция например для перемножения матриц или метода Гаусса решения линейных систем. Для подсчета матриц
воспользуемся простым фактом из линейной алгебры: столбцы этой матрицы являются результатом применения преобразования на стандартном базисе
/* Бинарные матрицы 8x8 соответствующие умножению */ static uint64_t gfni_matrix[256]; const uint8_t irreducible_poly = 0x1b; // x^4+x^3+x+1 void InitGFNI() { for (int16_t y = 0; y < 256; ++y) { uint64_t mt = 0; uint8_t row = y; // На каждой итерации цикла вычисляется произведение y // на очердной базисный вектор, результат записывается в mt for (size_t i = 0, shift = 0; i < 8; ++i, shift += 8) { mt |= ((uint64_t)row << shift); row = (row << 1) ^ ((row >> 7) * irreducible_poly); } // Фактически в mt уже лежит нужная матрица, но порядок элементов // в GF2P8AFFINEQB немного странный, нужно немного попереставлять биты gfni_matrix[y] = 0; for (size_t i = 0; i < 8; ++i) { for (size_t j = 0; j < 8; ++j) { gfni_matrix[y] |= ((mt >> (8 * i + j)) & 1) << (8 * j + (7 - i)); } } } } // Производит x[0..63] += y[0..63] * c void FMA_64(uint8_t* x, const uint8_t* y, uint8_t c) { __m512i c_matrix = _mm512_set1_epi64(gfni_matrix[c]); auto x_reg = _mm512_loadu_epi8(x); auto y_reg = _mm512_loadu_epi8(y); x_reg = _mm512_xor_epi64(x_reg, _mm512_gf2p8affine_epi64_epi8(y_reg, c_matrix, 0)); _mm512_storeu_epi8(x, x_reg); }
Подробнее на этот пример можно посмотреть в репозитории. Отдельно для тех, кто работает с вычислением в бинарных полях, отмечу, что указанный выше метод может быть использован для работы с нормальным базисом или базисом Кантора.
Ну и под конец отмечу, что умножение матриц может быть использовано само по себе, наличие GF2P8AFFINEQB скорее всего делает бесполезным метод четырех русских.
Ссылки
Intel Intrinsics Guide.
Документация по SIMD-инструкциям Intel (зеркало на github).
URL: https://intrinsics.idma88.ru/Intel Corporation. Galois Field New Instructions (GFNI) Technology Guide.
Документация по GFNI
URL: https://builders.intel.com/docs/networkbuilders/galois-field-new-instructions-gfni-technology-guide-1-1639042826.pdfARM Architecture Reference.
Документация по ARM NEON Intrinsics (vqtbl1qu8 и др.).
URL: https://developer.arm.com/architectures/instruction-sets/intrinsics/vqtbl1qu8catid / gf256.
Реализация операций над полем GF(256) на C++.
URL: https://github.com/catid/gf256/blob/master/gf256.cpp#L475H. Peter Anvin. The mathematics of RAID-6.
Коды Рида–Соломона для RAID 6 с ускорением векторных операций через PSHUFB, использующихся в том числе в catid/gf256
URL: http://kernel.org/pub/linux/kernel/people/hpa/raid6.pdfIntel ISA-L (Intel Intelligent Storage Acceleration Library).
Коды Рида-Соломона от Intel на С/ASM с использованием GFNI.
URL: https://github.com/intel/isa-l/blob/master/erasurecode/gfvect_gfni.inc#L60Malkovsky / galois.
Мой репозиторий со сравнением производительности векторных операций над GF(256).
URL: https://github.com/Malkovsky/galois/tree/main
Друзья и коллеги! С удовольствием хотел бы прорекламировать CS Space — открытый научный клуб по CS-related темам; идейных последователей питерского Computer Science Club (и CS Center), расформировавшегося после известных событий. Ребята организуют крутые лекции и курсы по CS от профессионалов своего дела, да еще и помогают мне с написанием научно-популярных статей!
Сайт сообщества: csspace.io
Telegram-канал: t.me/csspace
Если вам понравилась статья — поставьте плюс, автору всегда приятно когда его работу ценят. Возможно вас также заинтересует мой канал А зачем это нужно? где я рассказываю о том, что математику и алгоритмы придумали не только для собеседований в бигтехи.
