Ваши данные RGB упакованы, поэтому нам не нужно заботиться о границах пикселей. Проблема заключается в упаковке каждого второго байта массива. (По крайней мере, в каждой строке вашего изображения; если вы используете шаг строки 16 или 32B, отступ может быть не целым числом пикселей.)
Это можно эффективно сделать с помощью перетасовки SSE2, AVX или AVX2. (Также AVX512BW и, возможно, даже больше с AVX512VBMI, но первые процессоры AVX512VBMI, вероятно, не будут иметь очень эффективной vpermt2b
, перетасовка байтов с пересечением дорожек с двумя входами.)
Вы можете использовать SSSE3 pshufb
для захвата нужных вам байтов, но это всего лишь перетасовка с 1 входом, которая даст вам 8 байтов на выходе. Сохранение 8 байтов за раз требует больше команд сохранения, чем сохранение 16 байтов за раз. (Вы также были бы узким местом в пропускной способности тасования на процессорах Intel со времен Haswell, которые имеют только один порт тасования и, следовательно, пропускную способность тасования один раз за такт). (Можно также рассмотреть вариант 2xpshufb
+ por
для заполнения хранилища 16 байт, и это может быть хорошо для Ryzen. Используйте 2 разных вектора управления перемешиванием, один из которых помещает результат в младшие 64 байта, а другой — в старшие 64 байта. См. Преобразовать 8 16-битных регистров SSE в 8-битные данные < /а>).
Вместо этого лучше использовать _mm_packus_epi16 (packuswb
). Но поскольку он насыщается вместо того, чтобы отбрасывать байты, которые вам не нужны, вам нужно вводить данные, которые вы хотите сохранить в младшем байте каждого 16-битного элемента.
В вашем случае это, вероятно, старший байт каждого компонента RGB16, отбрасывая 8 младших битов из каждого компонента цвета. то есть _mm_srli_epi16(v, 8)
. Чтобы обнулить старший байт в каждом 16-битном элементе, используйте вместо него _mm_and_si128(v, _mm_set1_epi16(0x00ff))
. (В таком случае не обращайте внимания на все эти вещи, связанные с использованием невыровненной нагрузки для замены одной из смен; это простой случай, и вы должны просто использовать два И для питания PACKUS.)
Это примерно так, как gcc и clang автоматически векторизуют это, в -O3
. За исключением того, что они и портят, и тратят впустую важные инструкции (https://gcc.gnu.org/bugzilla/show_bug.cgi?id=82356, https://bugs.llvm.org/show_bug.cgi?id=34773). Тем не менее, автоматическая векторизация с помощью SSE2 (базовый уровень для x86-64) или NEON для ARM или чего-то еще — это хороший безопасный способ получить некоторую производительность без риска внесения ошибок при ручной векторизации. За исключением ошибок компилятора, все, что они генерируют, будет правильно реализовывать семантику C этого кода, который работает для любого размера и выравнивания:
// gcc and clang both auto-vectorize this sub-optimally with SSE2.
// clang is *really* sub-optimal with AVX2, gcc no worse
void pack_high8_baseline(uint8_t *__restrict__ dst, const uint16_t *__restrict__ src, size_t bytes) {
uint8_t *end_dst = dst + bytes;
do{
*dst++ = *src++ >> 8;
} while(dst < end_dst);
}
Но пропускная способность векторного сдвига ограничена 1 за такт во многих микроархитектурах (Intel до Skylake, AMD Bulldozer/Ryzen). Кроме того, до AVX512 не существовало ассемблерной инструкции load+shift, поэтому все эти операции трудно выполнить через конвейер. (т. е. мы легко становимся узким местом на внешнем интерфейсе.)
// Compilers auto-vectorize sort of like this, but with different
// silly missed optimizations.
// This is a sort of reasonable SSE2 baseline with no manual unrolling.
void pack_high8(uint8_t *restrict dst, const uint16_t *restrict src, size_t bytes) {
// TODO: handle non-multiple-of-16 sizes
uint8_t *end_dst = dst + bytes;
do{
__m128i v0 = _mm_loadu_si128((__m128i*)src);
__m128i v1 = _mm_loadu_si128(((__m128i*)src)+1);
v0 = _mm_srli_epi16(v0, 8);
v1 = _mm_srli_epi16(v1, 8);
__m128i pack = _mm_packus_epi16(v0, v1);
_mm_storeu_si128((__m128i*)dst, pack);
dst += 16;
src += 16; // 32 bytes, unsigned short
} while(dst < end_dst);
}
Вместо смещения мы можем загружать с адреса, смещенного на один байт, чтобы нужные нам байты находились в нужном месте. И маскирование нужных нам байтов имеет хорошую пропускную способность, особенно с AVX, где компилятор может сложить загрузку+и в одну инструкцию. Если входные данные выровнены по 32 байтам, и мы проделываем этот трюк со смещением только для нечетных векторов, наша загрузка никогда не пересечет границу строки кэша. С развертыванием цикла это, вероятно, лучший выбор для SSE2 или AVX (без AVX2) на многих процессорах.
Без AVX внутренний цикл занимает 6 инструкций (6 мопов) на 16-битный вектор результатов. (С AVX всего 5, так как нагрузка сворачивается в и). Поскольку это абсолютно узкие места во внешнем интерфейсе, развертывание цикла очень помогает. gcc -O3 -funroll-loops
выглядит неплохо для этой векторизованной вручную версии, особенно с gcc -O3 -funroll-loops -march=sandybridge
для включения AVX.
// take both args as uint8_t* so we can offset by 1 byte to replace a shift with an AND
// if src is 32B-aligned, we never have cache-line splits
void pack_high8_alignhack(uint8_t *restrict dst, const uint8_t *restrict src, size_t bytes) {
uint8_t *end_dst = dst + bytes;
do{
__m128i v0 = _mm_loadu_si128((__m128i*)src);
__m128i v1_offset = _mm_loadu_si128(1+(__m128i*)(src-1));
v0 = _mm_srli_epi16(v0, 8);
__m128i v1 = _mm_and_si128(v1_offset, _mm_set1_epi16(0x00FF));
__m128i pack = _mm_packus_epi16(v0, v1);
_mm_store_si128((__m128i*)dst, pack);
dst += 16;
src += 32; // 32 bytes
} while(dst < end_dst);
}
С AVX, возможно, стоит выполнить как v0
, так и v1
с and
, чтобы уменьшить узкое место во внешнем интерфейсе за счет разделения строк кэша. (И случайные разрывы страниц). Но, возможно, нет, в зависимости от uarch, и если ваши данные уже смещены или нет. (Разветвление на это может стоить того, так как вам нужно максимально увеличить пропускную способность кеша, если данные горячие в L1D).
С AVX2 версия 256b с загрузкой 256b должна хорошо работать на Haswell/Skylake. При src
выравнивании по 64B загрузка смещения по-прежнему никогда не будет разделять строку кэша. (Он всегда будет загружать байты [62:31]
строки кэша, а загрузка v0
всегда будет загружать байты [31:0]
). Но пакет работает в пределах 128-битных дорожек, поэтому после пакета вам нужно перетасовать (с vpermq
), чтобы расположить 64-битные фрагменты в правильном порядке. Посмотрите, как gcc автоматически векторизует скалярную базовую версию с помощью vpackuswb ymm7, ymm5, ymm6
/vpermq ymm8, ymm7, 0xD8
.
С AVX512F этот трюк перестает работать, потому что 64-битная загрузка должна быть выровнена, чтобы оставаться в пределах одной 64-битной строки кэша. Но с AVX512 доступны различные перетасовки, а пропускная способность uop ALU более ценна (на Skylake-AVX512, где порт 1 отключается, пока 512b uop находятся в полете). Итак, v
= load+shift -> __m256i packed = _mm512_cvtepi16_epi8(v)
может работать хорошо, даже если он сохраняет только 256b.
Правильный выбор, вероятно, зависит от того, обычно ли ваши src и dst выровнены по 64 байтам. У KNL нет AVX512BW, так что это, вероятно, в любом случае относится только к Skylake-AVX512.
во всяком случае, ничто не сравнится с хорошим оптимизированным циклом ассемблера.
person
Peter Cordes
schedule
28.09.2017