Векторные инструкции. Часть 1

Векторные вычисления — такие компьютерные вычисления, когда при выполнении одной инструкции процессора производится не одна операция, а одновременно несколько однотипных операций над несколькими порциями данных. Иначе этот принцип называется SIMD — от английского Single Instruction, Multiple Data. Название возникло из очевидной аналогии с векторной алгеброй: операции между векторами обозначаются одним символом, но подразумевают, что одновременно выполняются несколько арифметических действий над компонентами вектора.

Изначально векторные вычисления выполнялись специализированными сопроцессорами, которые являлись основной частью суперкомпьютеров. В 1990-е годы в некоторых процессорах архитектуры x86 и нескольких других появились векторные расширения: специальные регистры с повышенной разрядностью и специальные векторные инструкции, оперирующие этими регистрами.

Векторные инструкции применяются там, где требуется выполнение множества однотипных операций и высокая производительность вычислений. Это всевозможные задачи вычислительной математики и математического моделирования, компьютерная графика и компьютерные игры. Без векторных вычислений сегодня невозможно достичь такой производительности вычислительной системы, которая требуется для обработки видеосигналов и, в особенности, кодирования и декодирования видео. Необходимо отметить, что для некоторых задач и алгоритмов векторные инструкции не увеличивают производительность.

В этой статье мы привели примеры использования векторных инструкций и реализации нескольких алгоритмов и функций с их помощью. Примеры в основном взяты из области обработки изображений и сигналов, но они могут оказаться полезными и программистам, работающими над задачами в других областях. Векторные инструкции помогают увеличить производительность, хотя и не гарантируют этого: от программиста требуется не только внимательность и аккуратность, но нередко и изобретательность, чтобы в полной мере использовать возможности, предоставляемые компьютером.

Инструкции и регистры

Векторные вычисления — такие вычисления, когда при выполнении одной инструкции процессора производится одновременно несколько однотипных операций. Этот принцип в настоящее время реализован не только в специализированных процессорах, но и в процессорах архитектуры x86 и ARM в виде векторных расширений. Эти расширения представляют собой специальные векторные регистры с повышенной относительно регистров общего назначения разрядностью. Для работы с этими регистрами имеются специальные векторные инструкции, которые дополняют систему инструкций процессора.

Рис.1 Скалярные и векторные вычисления
Рис.1 Скалярные и векторные вычисления

Как правило, векторные инструкции реализуют те же операции, что и скалярные (обычные) инструкции (см. рис. 1), но благодаря большому объёму обрабатываемых данных производительность этих инструкций выше. Если для регистра общего назначения при выполнении некоторой инструкции предполагается, что в нём находится только одна порция данных определённого типа (целое число определённой разрядности, число с плавающей запятой), то в векторном регистре одновременно находится столько независимых порций данных определённого типа, сколько позволяет разместить ёмкость регистра. И такое же количество одновременных независимых операций может быть произведено над этими данными при выполнении векторной инструкции — и во столько же раз повышается производительность вычислений. Повысить производительность процессора, выполняя несколько одинаковых операций одновременно, — основная задача векторных расширений.

В процессорах архитектуры x86 первым векторным расширением был набор инструкций MMX, оперирующих восемью 64-битными регистрами MM0-MM7. MMX сменили более производительные 128-битные инструкции SSE (инструкции для работ числами с плавающей запятой) и SSE2 (целочисленные инструкции и инструкции для работ с числами с плавающей запятой двойной точности), оперирующие регистрами xmm0-xmm7. Позже появились наборы 128-битных инструкций SSE3, SSSE3, SSE4.1 и SSE4.2, которые дополнили SSE и SSE2 несколькими полезными инструкциями. Большинство инструкций из перечисленных наборов используют два регистра-операнда, результат записывается в один из этих регистров, а его первоначальное содержимое теряется.

Следующий шаг в развитии векторных расширений — ещё более производительные 256-битные инструкции AVX и AVX2, которые оперируют 256-битными регистрами ymm0-ymm15. Кроме того, эти инструкции используют три регистра-операнда: исходные данные содержатся в двух регистрах, результат операции записывается в третий регистр, а содержимое двух других регистров остаётся неизменным. Новейший на сегодняшний день набор векторных инструкций — AVX-512, который оперирует 32 512-битными регистрами zmm0-zmm31. AVX-512 используется в некоторых серверных процессорах для высокопроизводительных вычислений.

С массовым появлением 64-битных процессоров инструкции MMX признаны устаревшими. Инструкции SSE и SSE2 с появлением AVX и AVX2 не вышли из употребления и продолжают активно использоваться. В процессорах x86 сохраняется обратная совместимость: если процессором поддерживается AVX2, то им поддерживаются и SSE/SSE2, а также SSE3, SSSE3, SSE4.1 и SSE4.2. Аналогично, процессор с поддержкой, например, SSSE3, поддерживает и все более ранние наборы инструкций.

Для процессорной архитектуры ARM разработано векторное расширение NEON. Разрядность векторных инструкций — 64 и 128 бит, которые оперируют тридцатью двумя 64-битными регистрами либо шестнадцатью 128-битными (в ARM64 имеется 32 128-битных регистров).

Поскольку векторные инструкции привязаны к конкретной процессорной архитектуре (а нередко — и к конкретному процессору), то программы, использующие их, становятся непереносимыми. Следовательно, для достижения переносимости необходимо делать несколько реализаций одного и того же алгоритма с использованием разных наборов инструкций.

Интринсики

Каким образом программист может использовать векторные инструкции? Прежде всего, их можно использовать в ассемблерном коде.

Можно получить доступ к векторным инструкциям и в программе на языке высокого уровня (в частности, C/C++) без ассемблерных вставок. Для этого используются так называемые интринсики (intrinsics) — встроенные объекты компилятора. В заголовочном файле объявлен один или несколько типов данных (с точки зрения программиста, это массив фиксированной длины, но без возможности доступа к элементам этого массива), переменной одного из этих типов соответствует векторный регистр. Также в заголовочном объявлены функции, которые принимают аргументы, возвращают значения указанных выше типов и производят с точки зрения программиста те же операции над данными, что и соответствующие им векторные инструкции. В действительности же настоящей программной реализации этих функций не существует: компилятор при генерации объектного кода заменяет вызов функции векторной инструкцией. Таким образом, с помощью интринсиков можно написать программу на языке высокого уровня, близкую или равную по производительности программе, написанной на языке ассемблера.

Для доступа к интринсикам достаточно подключить соответствующий заголовочный файл. Интринсики не являются частью стандартов языков C/C++, но поддерживаются основными компиляторами: GCC, Clang, MSVC, Intel.

Интринсики позволяют также упорядочить работу с разнообразными типами обрабатываемых данных. Важно отметить, что процессору (по крайней мере, в архитектуре x86) недоступна информация о типе данных, содержащихся в регистре. При выполнении той или иной векторной инструкции они интерпретируются как данные определённого, связанного с инструкцией типа: числа с плавающей запятой, целого числа некоторой разрядности со знаком или без знака. Контролировать корректность производимых вычислений при этом должен программист, что требует немалого внимания. Тем более, иногда тип данных может изменяться: например, при целочисленном умножении разрядность произведения равна сумме разрядностей множителей. Интринсики позволяют в некоторой степени упростить задачу.

Так, векторным регистрам xmm (SSE) соответствуют три типа данных [1]:

__m128, «массив» из четырёх чисел с плавающей запятой с одинарной точностью;

__m128d, «массив» из двух чисел с плавающей запятой двойной точности;

__m128i, 128-битный регистр, который можно рассматривать как «массив» 8-, 16, 32- и 64-битных чисел. Поскольку конкретная векторная инструкция работает, как правило, с одним из трёх типов данных (число с плавающей запятой одинарной точности, двойной точности, целочисленный), то и аргументы функций-векторных инструкций имеют один из трёх указанных типов. Подобным образом устроена и система типов AVX2: в ней имеются типы __m256 (с плавающей запятой), __m256d (двойной точности) и __m256i (целочисленный).

Интринсики NEON реализуют ещё более развитую систему типов [2]. 128-битному регистру соответствуют типы int32x4_t, int16x8_t, int8x16_t, float32x4_t, float64x2_t. В NEON имеются и типы данных, содержащие несколько регистров, например int8x16x2_t. В подобной системе в любой момент известны конкретный тип и разрядность содержимого регистра, и меньше вероятность допустить ошибку там, где преобразуется тип данных и меняется их разрядность.

Приведём пример простой функции, реализованной с помощью набора инструкций SSE2.

// 1.2.1: Example of SSE2 intrinsics // for int32_t #include <stdint.h> // for SSE2 intrinsics #include <emmintrin.h> void bar(void) { int32_t array_a[4] = {0,2,1,2}; // 128 bit int32_t array_b[4] = {8,5,0,6}; int32_t array_c[4]; __m128i a,b,c; a = _mm_loadu_si128((__m128i*)array_a); // loading array_a into register a b = _mm_loadu_si128((__m128i*)array_b); c = _mm_add_epi32(a, b); // must be { 8,7,1,8 } _mm_storeu_si128((__m128i*)array_c, c); }

В этом примере содержимое массива array_a загружается в один векторный регистр, а массива array_b — в другой. Затем соответствующие 32-битные элементы регистров складываются, и результат записывается в третий регистр, а затем копируется в массив array_c. В этом примере можно отметить ещё одну особенность интринсиков: _mm_add_epi32 принимает два аргумента-регистра и возвращает одно значение-регистр. Но в действительности инструкция paddd, которой соответствует _mm_add_epi32, имеет только два регистра-операнда, в один из которых записывается результат операции с потерей первоначального содержимого регистра. Чтобы сохранить содержимое регистров при компиляции c = _mm_add_epi32(a, b), компилятор добавляет дополнительные операции копирования из регистра в регистр.

Имена интринсиков выбраны так, чтобы облегчить чтение исходного текста программ. В архитектуре x86 имя состоит из трёх частей: префикса, обозначений операции и типа скалярных данных (рисунок 2, а.). Префикс обозначает разрядность векторного регистра: _mm_ для 128 бит, _mm256_ для 256 бит и _mm512_ для 512 бит соответственно. Обозначения некоторых типов данных приведены в таблице 1. Подобным образом выбраны имена интринсиков и в наборе ARM NEON (рисунок 2, б.). Имеется, напомним, два типа векторных регистров (64 и 128-битные), и буква q обозначает, что инструкция специализируется на 128-битных регистрах.

Рис.2 Имена интринсиков в SSE2 (а) и ARM NEON (b)
Рис.2 Имена интринсиков в SSE2 (а) и ARM NEON (b)
Таблица 1: Обозначение типов данных интринсиков архитектуры х86 (b)
Таблица 1: Обозначение типов данных интринсиков архитектуры х86 (b)

Имена типов данных (__m128i и другие) и имена функций-интринсиков фактически стали стандартными в разных компиляторах. Далее в статье векторные инструкции будут именоваться не мнемокодом, а соответствующим им именем интринсика.

Важнейшие векторные инструкции

В этом разделе рассказывается о важнейших классах инструкций. Приведены примеры часто употребляемых и полезных инструкций в основном из архитектуры x86, но уделено внимание и инструкциям ARM NEON.

Обмен данными с оперативной памятью

Чтобы процессор произвел какие-либо действия с данными, которые находятся в оперативной памяти, необходимо сначала загрузить эти данные в регистр процессора. А после завершения обработки данные необходимо записать обратно в оперативную память.

Большинство векторных инструкций работает в формате «регистр — регистр», то есть операндами инструкций являются векторные регистры, и в них же записывается результат работы инструкций. Для обмена данными с оперативной памятью есть ряд специализированных инструкций.

Инструкция _mm_loadu_si128(__m128i* addr) извлекает из оперативной памяти непрерывный массив целочисленных данных длиной 128 бит с начальным адресом addr и записывает в выбранный векторный регистр. В противоположность ей, инструкция _mm_storeu_si128(__m128i* addr, __m128i a) копирует в оперативную память, начиная с адреса addr, непрерывный массив данных длиной 128 бит, которые содержатся в регистре a. При использовании этих инструкций адрес addr может быть произвольным (естественно, при чтении и записи не должен происходить выход за пределы массива). Инструкции _mm_load_si128 и _mm_store_si128 аналогичны приведённым выше и потенциально более производительны. Но они требуют, чтобы addr был кратен 16 байтам (иначе говоря, выровнен по границе 16 байт), иначе при их выполнении произойдёт аппаратное исключение.

Для загрузки и записи данных с плавающей запятой одинарной и двойной точности (длиной 128 бит) существуют специализированные инструкции _mm_loadu_ps и _mm_storeu_ps и, соответственно, _mm_loadu_pd и _mm_storeu_pd.

Нередко возникает необходимость загрузить данные в меньшем количестве, чем вмещает векторный регистр. Инструкция _mm_loadl_epi64(__m128i* addr) извлекает из оперативной памяти непрерывный массив данных длиной 64 бит, начиная с адреса addr и записывает их в младшую половину выбранного векторного регистра, биты же старшей половины регистра устанавливает нулевыми. Инструкция _mm_storel_epi64(__m128i* addr, __m128i a), обратная ей, записывает в оперативную память младшие 64 бита регистра addr.

Инструкция _mm_cvtsi32_si128(int32_t a) копирует 32 бита целочисленной переменной в младшие 32 бита векторного регистра, остальные биты регистра устанавливаются нулевыми. Напротив, _mm_cvtsi128_si32(__m128i a) копирует младшие 32 бита регистра в целочисленную переменную.

Логические операции и операции сравнения

В наборе SSE2 представлены инструкции, выполняющие следующие логические операции: «И», «ИЛИ», «исключающее ИЛИ», «И-НЕ». Имена инструкций, соответственно, следующие: _mm_and_si128, _mm_or_si128, _mm_xor_si128 и _mm_andnot_si128. Инструкции полностью аналогичны побитовым операциям, выполняемым над целыми числами, только разрядность числа равна не 32 или 64, а 128 битам.

Часто используемая инструкция _mm_setzero_si128(), которая устанавливает все биты регистра-приёмника нулевыми, реализована при помощи операции «исключающее ИЛИ», у которой оба операнда — один и тот же регистр.

С логическими инструкциями тесно связаны инструкции сравнения. Эти инструкции сопоставляют друг другу соответствующие элементы двух регистров-источников и проверяют выполнение некоторого условия (равенство либо неравенство). Если условие выполняется, то они устанавливают все биты элемента в регистре-приёмнике равными 1, в противном случае все биты устанавливаются равными 0. Например, инструкция _mm_cmpeq_epi32(__m128i a, __m128i b) проверяет 32-битные элементы регистров a и b на равенство. Результаты проверки нескольких разных условий можно объединить при помощи логических инструкций.

Арифметические операции и сдвиги

Инструкции, принадлежащие этой группе, несомненно, самые востребованные.

Для вычислений с плавающей точкой и в x86, и в ARM есть инструкции, которые реализуют все четыре арифметические операции и вычисляют квадратный корень для чисел однократной и двойной точности. В х86 для чисел однократной точности эти инструкции следующие: _mm_add_ps, _mm_sub_ps, _mm_mul_ps, _mm_div_ps и _mm_srqt_ps.

Приведём простой пример использования арифметических инструкций с плавающей запятой. Здесь, аналогично примеру в разделе 2 суммируются элементы двух массивов src0 и src1, а результат записывается в массив dst. При этом количество элементов, которые необходимо просуммировать, задано параметром len. В том случае, если len не кратно количеству элементов, которые вмещает векторный регистр (в нашем случае это будет четыре и два), часть элементов обрабатывается обычным способом, без векторизации.

// 1.3.1 Sum of elements of two arrays /* necessary for SSE and SSE2 */ #include <emmintrin.h> void sum_float( float src0[], float src1[], float dst[], size_t len) { __m128 x0, x1; // floating-point, single precision size_t len4 = len & ~0x03; for(size_t i = 0; i < len4; i+=4) { x0 = _mm_loadu_ps(src0 + i); // loading of four float values x1 = _mm_loadu_ps(src1 + i); x0 = _mm_add_ps(x0,x1); _mm_storeu_ps(dst + i, x0); } for(size_t i = len4; i < len; i++) { dst[i] = src0[i] + src1[i]; } } void sum_double( double src0[], double src1[], double dst[], sizе_t len) { __m128d x0, x1; // floating-point, double precision size_t len2 = len & ~0x01; for(size_t i = 0; i < len2; i+=2) { x0 = _mm_loadu_pd(src0 + i ); //loading of two double values x1 = _mm_loadu_pd(src1 + i ); x0 = _mm_add_pd(x0,x1); _mm_storeu_pd(dst + i, x0); } if(len2 != len) { dst[len2] = src0[len2] + src1[len2]; } }

Для работы с целочисленными данными некоторой арифметической операции, как правило, есть несколько однотипных инструкций, каждая из которых специализируется на данных определённой разрядности. Например, операции сложения и вычитания. Для 16-битных целых чисел со знаком есть инструкции _mm_add_epi16 и _mm_sub_epi16 (выполняющие сложение и, соответственно, вычитание). Аналогичные им инструкции имеются и для разрядности 8, 32 и 64 бит. То же касается и логического сдвига влево и вправо, который реализован для разрядности 16, 32 и 64 бит (для 16 бит инструкции _mm_slli_epi16 и _mm_srli_epi16 соответственно). Арифметический же сдвиг вправо реализован только для разрядности 16 и 32 бит: это инструкции _mm_srai_epi16 и _mm_srai_epi32. ARM NEON также предоставляет инструкции для этих операций: для разрядностей 8, 16, 32 и 64 бит как для чисел со знаком, так и без знака.

Инструкции _mm_slli_si128(__m128i a, int imm) и _mm_srli_si128(__m128i a, int imm) интерпретируют содержимое регистра как 128-битное число, которое сдвигают на imm байт (не бит!) влево и, соответственно, вправо.

Рис.3 Горизонтальное сложение
Рис.3 Горизонтальное сложение

В наборах инструкций SSE3 и SSSE3 появляются инструкции для горизонтального сложения (рисунок 3): _mm_hadd_ps(__m128 a, __m128 b), _mm_hadd_pd(__m128d a, __m128d b), _mm_hadd_epi16(__m128i a, __m128i b) и _mm_hadd_epi32(__m128i a, __m128i b). При горизонтальном сложении складываются соседние элементы одного и того же регистра. Добавлены инструкции и для горизонтального вычитания (_mm_hsub_ps и другие), которые таким же образом производят вычитание. Похожие инструкции, реализующие парное сложение (например, vpaddq_s16(int16x8_t a, int16x8_t b)), есть среди инструкций ARM NEON.

При целочисленном умножении разрядность произведения в общем случае равна сумме разрядностей сомножителей. Тогда, если умножить, например, 16-битные элементы одного регистра на соответствующие элементы другого, то в общем случае результаты произведений получаются 32-битные, и для их размещения потребуется два регистра, а не один.

Инструкция _mm_mullo_epi16(__m128i a, __m128i b) производит умножение 16-битных элементов регистров a и b, и записывает в регистр-приёмник младшие 16 бит 32-битного произведения. Напротив, _mm_mulhi_epi16(__m128i a, __m128i b) записывает в регистр-приёмник старшие 16 бит произведения. Результаты работы этих инструкций можно объединить и получить 32-битные произведения, если воспользоваться инструкциями _mm_unpacklo_epi16 и _mm_unpackhi_epi16, о которых речь пойдёт далее. Разумеется, если сомножители достаточно малы, то достаточно и одной _mm_mullo_epi16.

Рис.4 Инструкция _mm_madd_epi16
Рис.4 Инструкция _mm_madd_epi16

Инструкция _mm_madd_epi16(__m128i a, __m128i b) производит умножение 16-битных элементов регистров a и b, а затем складывает получившиеся соседние 32-битные произведения (рисунок 4). Эта инструкция оказывается особенно полезной при реализации всевозможных фильтров, дискретно-косинусных и других преобразований, где требуется много умножений совместно со сложениями: произведения сразу преобразуются в удобный 32-битный формат, а количество необходимых операций сложения сокращается.

Инструкции умножения ARM NEON весьма разнообразны. Имеются инструкции с увеличением разрядности произведения (например, vmull_s16) и без него; инструкции, которые умножают вектор на скаляр (например, vmul_n_f32). Инструкции, подобной _mm_madd_epi16, в наборе NEON нет, зато есть инструкции, выполняющие умножение с накоплением, которое выражается формулой: ai=ai+(bici), i=1..n. Подобные инструкции имеются и для архитектуры x86 (набор инструкций FMA), но только для чисел с плавающей запятой.

Что касается векторного целочисленного деления, то оно не реализовано ни в архитектуре x86, ни в ARM.

Перестановка и перемешивание

Для группы инструкций, о которых далее пойдёт речь, не существует аналогичных скалярных инструкций процессора. При их выполнении не образуются новые значения: либо производится перестановка данных внутри регистра, либо данные, взятые из нескольких регистров-источников, записываются в определённом порядке в регистр-приёмник. На первый взгляд, эти инструкции мало полезны, но в действительности их значение очень велико. Без них многие алгоритмы невозможно реализовать эффективно.

Рис.5 Копирование по шаблону
Рис.5 Копирование по шаблону

Несколько векторных инструкций в x86 и ARM реализуют копирование по шаблону (рисунок 5). Пусть имеются массив-источник, массив-приёмник, а также массив индексов, количество элементов в котором равно количеству элементов массива-приёмника, и при этом каждый элемент массива индексов соответствует элементу массива-приёмника. Значение элемента массива индексов указывает на тот элемент массива-источника, который нужно скопировать в соответствующий элемент массива-приёмника. Задавая различные индексы, можно осуществить всевозможные перестановки элементов, их дублирование.

В векторных инструкциях в качестве массива-источника и массива приёмника выступают векторные регистры или их группы. В качестве массива индексов может выступать также векторный регистр или же целочисленная константа, группы битов которой соответствуют элементам регистра-приёмника и кодируют элементы источника.

Одна из инструкций, реализующих принцип копирования по шаблону, — инструкция SSE2 _mm_shuffle_epi32(__m128i a, const int im), которая копирует выбранные 32-битные элементы регистра-источника в регистр-приёмник. В качестве массива индексов выступает второй операнд-целочисленная константа, значение которой задаёт шаблон копирования. Инструкция обычно используется совместно со стандартным макросом _MM_SHUFFLE, который делает задание шаблона копирования более наглядным. Например, при выполнении

a = _mm_shuffle_epi32(b,_MM_SHUFFLE(0,1,2,3));

32-битные элементы b записываются в регистр a в обратном порядке. А при выполнении

a = _mm_shuffle_epi32(b,_MM_SHUFFLE(2,2,2,2));

значения всех элементов регистра a становятся равными одному и тому же значению, а именно значению третьего элемента регистра b.

Инструкции _mm_shufflelo_epi16 и _mm_shufflehi_epi16 работают аналогично, но копируют выбранные 16-битные элементы из младшей и, соответственно, старшей половины регистра, а оставшуюся половину регистра копируют в регистр-приёмник без изменений. Покажем для примера, как с помощью этих инструкций и _mm_shuffle_epi32 можно переставить в обратном порядке 16-битные элементы 128-битного регистра всего за три операции. Вот как это делается:

// a: a0 a1 a2 a3 a4 a5 a6 a7

a = _mm_shuffle_epi32(a, _MM_SHUFFLE(1,0,3,2)); // a4 a5 a6 a7 a0 a1 a2 a3

a = _mm_shufflelo_epi16(a, _MM_SHUFFLE(0,1,2,3)); // a7 a6 a5 a4 a0 a1 a2 a3

a = _mm_shufflehi_epi16(a, _MM_SHUFFLE(0,1,2,3)); // a7 a6 a5 a4 a3 a2 a1 a0

Сначала меняются местами старшая и младшая половины регистра, а затем 16-битные элементы каждой из половин переставляются в обратном порядке.

Инструкция _mm_shuffle_epi8(__m128i a, __m128i i) из набора SSSE3 тоже осуществляет копирование по шаблону (правда, регистр-источник и регистр-приёмник у этой инструкции совпадают, так что это скорее «перестановка по шаблону»), но работает с байтами. Индексы задаются значениями байтов второго операнда — регистра. Эта инструкция позволяет осуществить гораздо более разнообразные перестановки данных, чем описанные выше инструкции, и благодаря этому во многих случаях возможно упростить и ускорить вычисления. Так, действие, показанное в примере выше, можно осуществить единственной инструкцией:

a = _mm_shuffle_epi8(a, i);

Для этого значения байта i должны быть следующими (начиная с младшего байта): 4,15,12,13,10,11,8,9,6,7,4,5,2,3,0,1

В наборе ARM NEON копирование по шаблону реализуют несколько инструкций, которые работают с одним регистром-источником (например, vtbl1_s8 (int8x8_t a, int8x8_t idx)) либо с группой регистров (например, vtbl4_u8(uint8x8x4_t a, uint8x8_t idx)). Инструкция vqtbl1q_u8(uint8x16_t t, uint8x16_t idx) аналогична _mm_shuffle_epi8.

Рис.6 Перемешивание
Рис.6 Перемешивание

Другая операция, реализуемая векторными инструкциями — перемешивание. Пусть имеется массив A с элементами a0,a1,...,an, массив B, с элементами b0,b1,...,bn, … массив Z с элементами z0,z1,...,zn. При выполнении этой операции элементы этих массивов объединяются в один в следующем порядке : a0,b0...,z0,a1,b1,...,z1,...,an,bn,...,zn (рисунок 6). В векторных инструкциях и в этом случае в качестве исходных массивов выступают регистры-источники, и притом только два. Очевидно, что при выполнении этой операции объём данных остаётся постоянным, а значит, и регистров-приёмников тоже должно быть два.

В векторных инструкциях x68 регистр-приёмник может быть только один, поэтому инструкции, реализующие перемешивание, обрабатывают только половину входных данных. Так, инструкция _mm_unpacklo_epi16(__m128i a, __m128i b) перемешивает 16-битные элементы младших половин регистров a и b, а дополняющая её инструкция _mm_unpackhi_epi16(__m128i a, __m128i b) — старших. Таким же образом работают инструкции для разрядностей 8, 32 и 64 бита. Инструкции _mm_unpacklo_epi64 и _mm_unpackhi_epi64, по сути, просто объединяют младшие и, соответственно, старшие 64 бита двух регистров. Парные инструкции нередко используются совместно.

Аналогичные инструкции есть и среди инструкций ARM NEON (семейство инструкций vzip). Некоторые из инструкций используют не один, а два регистра-приёмника и, таким образом, обрабатывают входные данные полностью. Имеются и такие инструкции, которые производят обратную операцию (vuzp), и для которых нет аналогов среди инструкций x86.

Инструкция _mm_alignr_epi8(__m128i a, _m128i b, int imm) копирует в регистр-приёмник байты регистра-источника a, начиная с выбранного байта imm, а оставшиеся байты копирует из регистра b, начиная с младшего байта. Пусть байты регистра a имеют значения a0..a15, а байты регистра b — значения b0..b15. Тогда при выполнении

a = _mm_alignr_epi8(a, b, 5);

в регистр a будут записаны следующие байты: a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, b0, b1, b2, b3, b4. Подобные инструкции, которые работают не с байтами, а с элементами определённой разрядности, предоставляет ARM NEON [3].

Инструкции AVX и AXV2

Дальнейшее развитие векторных инструкций архитектуры x86 связано с появлением 256-битных инструкций AVX и AVX2. Что эти инструкции предоставляют разработчикам?

Прежде всего, вместо 8 (16) 128-битных регистров XMM имеется 16 256-битных регистров YMM0 – YMM15, младшие 128 бит которых являются векторными регистрами XMM. Инструкции, в отличие от SSE, принимают не два, а три регистра-операнда: два регистра-источника и один регистр-приёмник. При этом после выполнения инструкции первоначальное содержимое регистров-источников не теряется.

Почти все операции, реализованные в более ранних наборах инструкций SSE-SSE4.2, есть и в AVX/AVX2, прежде всего — арифметические. Для инструкций вроде _mm_add_epi32, _mm_madd_epi16, _mm_sub_ps, _mm_slli_epi16 и многих других имеются полностью аналогичные инструкции, но которые вдвое их производительнее.

Точных аналогов инструкций _mm_loadl_epi64 и _mm_cvtsi32_si128 (и соответствующих инструкций для вывода) в AVX/AVX2 нет. Взамен появились инструкции _mm256_maskload_epi32 и _mm256_maskload_epi64, которые загружают нужное количество 32- и 64-битных значений из памяти с использованием битовой маски.

Появились и новые инструкции _mm256_gather_epi32, _mm256_gather_epi64 (и аналогичные для чисел с плавающей запятой), которые загружают не непрерывный массив данных, а порции данных с использованием начального адреса и заданных смещений для каждой порции. Инструкции особенно полезны в случае, когда нужные данные расположены в памяти не последовательно, и требуется много операций, чтобы их извлечь и объединить.

В наборе AVX2 есть инструкции для перемешивания и перестановки данных, например _mm256_shuffle_epi32 и _mm256_alignr_epi8. Есть одна особенность, которая отличает их от других инструкций AVX/AVX2. Например, арифметические инструкции рассматривают регистр YMM как массив из 256 бит. Но указанные инструкции рассматривают YMM как два 128-битных регистра, и производят над ними операции точно таким же образом, каким их производит соответствующая инструкция SSE.

Например, регистр содержит следующие 32-битные элементы: A0, A1, A2, A3, A4, A5, A6, A7. Тогда, после выполнения

a = _mm256_shuffle_epi32(a, _M_SHUFFLE(0,1,2,3));

его содержимое станет: A3, A2, A1, A0, A7, A6, A5, A4.

Таким же образом работают и другие инструкции: _mm256_unpacklo_epi16, _mm256_shuffle_epi8, _mm256_alignr_epi8.

В AVX2 появились и новые инструкции для перестановки и перемешивания. Например, _mm256_permute4x64_epi64(__m256i, int imm) переставляет 64-битные элементы регистра таким же образом, каким mm_shuffle_epi32 переставляет 32-битные элементы.

Где получить информацию по векторным инструкциям?

Прежде всего, обратитесь к официальным сайтам производителей микропроцессоров. Intel имеет онлайн-справочник: там можно найти исчерпывающее описание интринсиков из всех наборов инструкций. Такой же справочник есть и для процессоров архитектуры ARM.

Если вы хотите изучить использование векторных инструкций на практике, обратитесь к свободным реализациям аудио- и видеокодеков. Такие проекты, как ffmpeg, VP9 и OpenHEVC используют векторные инструкции, а исходные тексты этих проектов позволяют увидеть примеры их использования.

Посмотреть список источников вы можете в полной версии статьи.

Автор:

Дмитрий Фарафонов

Инженер Elecard. Занимается оптимизацией аудио- и видеокодеков, а также программ для обработки аудио- и видеосигналов с 2015 года.
Начать дискуссию