Меня зовут Андрей Бакшаев, я ведущий инженер-программист в YADRO. Моя команда занимается разработкой и оптимизацией математических библиотек под архитектуру x86. До этого я 15 лет работал в Intel. Значительная часть моих задач заключалась в том, чтобы реализовывать некоторые алгоритмы обработки изображений и сигналов в довольно известной математической библиотеке IPP, максимально эффективно используя возможности процессоров. Я также исследовал производительность этих алгоритмов в процессорах на ранней стадии проектирования.
В статье я поделюсь своим опытом оптимизации низкоуровневого кода на языке C. Рассмотрим подсистему кэша и памяти процессоров и новые инструкции AVX-512. Разберем пример ускорения копирования байтового массива данных и посмотрим, как векторизованный код позволяет сократить время работы широко используемого алгоритма замены байтов по таблице с 619 до 34 мс, то есть примерно в 18 раз.
Подсистема кэша и памяти
Представим, что перед нами стоит типовая задача — скопировать массив байтов. Начнем с обычного кода на С и постараемся постепенно модифицировать его, повышая производительность.
Скалярный и векторизованный код
Реализуем простую функцию copy
:
// скалярный С
copy(char* src, char* dst, int len)
{
for(i=0;i<len;i++){
dst[i] = src[i];
}
}
Чем отличается скалярный код от векторизованного? Исполняемые арифметические инструкции в процессоре можно поделить на две категории:
-
Скалярные — инструкция выполняет действие, например, сложение или умножение, над одним элементом. К этой категории относятся большинство инструкций с регистрами общего назначения в x86.
-
Векторные — инструкция загружает из памяти или выполняет какие-то арифметические инструкции сразу с N элементами. N зависит от типа данных: char, short, float и так далее. Смысл в том, что обрабатывается целый вектор, то есть несколько элементов одновременно. За счет этого и получается ускорение вычислений.
Современные компиляторы поддерживают автовекторизацию для кода на языке С, что сильно упрощает процесс оптимизации.
Первое векторное расширение, появившееся в x86, называлось MMX (Multimedia Extension). Регистры были 64 бит, и одновременно обрабатывалось по 64/8=8 байт. Далее появилось расширение SSE (Streaming SIMD Extensions) с векторами по 128 бит (16 байт). На данный момент актуальны расширения AVX2 и AVX512 с векторами по 256 и 512 бит.
Посмотрим на примере, как выглядит векторизованный код:
// векторизованный avx2
#include "immintrin.h"
copy(char* src, char* dst, int len)
{
for(i=0;i<len;i+=32){
__m256i x0 =_mm256_loadu_si256(src+i);
_mm256_storeu_si256(dst+i, x0);
}
}
Здесь:
-
“immintrin.h”
— файл с упрощенно, это специального вида встроенные функции, позволяющие вызывать машинные инструкции непосредственно из языка C, в том числе и векторные.</p>" data-abbr="интринсики (intrinsics)">интринсиками языка C. -
x0
— объявление и использование векторного регистра. -
__m256i x0
— переменная, соответствующая 256-битному векторному регистру. -
_mm256_loadu_si256
— интринсик загрузки из памяти в 256-битный регистр.
Мы подключаем векторное расширение и немного модифицируем наш скалярный код функции copy
. В цикле i++
заменяем на i+=32
, поскольку теперь за одну итерацию одной командой loadu
мы загружаем 32 байта из памяти. Команда storeu
записывает данные в память. Такой код называется AVX2 векторизованным, поскольку он использует 256-битные регистры расширения AVX2.
Можно написать и AVX512 векторизованный код, который использует 512-битный регистр. Это позволит брать на одной итерации в 2 раза больше данных, по 64 байта:
// векторизованный avx512
#include "immintrin.h"
copy(char* src, char* dst, int len)
{
for(i=0;i<len;i+=64){
__m512i x0=_mm512_loadu_si512(src+i);
_mm512_storeu_si512(dst+i, x0);
}
}
Векторные интринсики практически соответствуют процессорным инструкциям и позволяют легко разрабатывать векторизованный код на языке C. Машинный код будет очень похожим.
Сравнение производительности кода
Мы написали три разных варианта кода для копирования байтового массива данных: скалярный С и векторизованные AVX2, AVX512. Давайте сравним их между собой по производительности.
Поскольку входной и выходной векторы имеют одинаковую длину, в кэш будет попадать суммарный объем данных, равный удвоенной длине, то есть 2 × длина вектора. Ее и нужно учитывать при изучении эффектов, связанных с кэшем. Посмотрим на график ниже. В нем используется единица измерения CPE (clock per element). Такты, затраченные на копирование вектора, делятся на его длину: CPE = clocks / length. Чем меньше CPE, тем быстрее.
Посмотрим детально, что происходит в L1-, L2-, L3-кэше и памяти:
Данные в L1. Объем L1 в нашем случае — 48 Кб. Данные помещаются сюда до длины ~48 Кб/2 = 24 Кб. AVX512-код обгоняет AVX2 почти в два раза.
Данные в L2. Объем данных превышает размер L1, но не превышает размер L2 примерно до длины 1,25 Мб/2 = 625 Кб. Здесь производительность сравнялась.
Данные в L3. До длины 4096 Кб данные помещаются в L3. Производительность всех трех версий кода снова на равных.
Память. Объем данных превышает суммарный размер кэша, и данные уходят в память. Производительность очень сильно снижается.
Выравнивание массива данных
На производительность кода могут влиять не только длины векторов, но и то, по каким адресам в памяти эти векторы расположены. А точнее то, насколько адреса векторов смещены относительно адреса, кратного 64.
Давайте измерим все смещения входного массива относительно такого адреса. Сначала выделим выровненную на 64 байта память с достаточным запасом. А далее будем измерять время копирования в таком цикле, смещаясь на 1 байт.
// измеряем все смещения 0...63 входного массива
src = _aligned_malloc( len+63 , 64);
dst = _aligned_malloc( len , 64);
for(off=0;off<=64;off++){
copy(src+off, dst, len);
}
Получаем такой график:
Сначала 512-битный код показывает себя лучше всех, затем его производительность снижается и восстанавливается, только когда прошло смещение на 64 байта.
Почему так? Для AVX-512 любое чтение по адресу, не кратному 64-м, затрагивает две кэш-линии (cache split). Это происходит потому, что длина кэш-линии — 64 байта (512 бит) и регистр в нашем AVX512-коде — 64 байта. А читать две кэш-линии дольше, чем одну.
Совет: рекомендуется выравнивать все массивы в приложениях на 64 байта. Если выровнять можно лишь один массив из двух, то смещенное чтение данных предпочтительнее записи.
Non-temporal инструкции
Иногда для повышения производительности мы можем заменить инструкции с обычного чтения на non-temporal чтение. Этот термин означает, что все данные будут записываться напрямую в память и не будут попадать в кэш.
// обычная запись _mm256_storeu_si256
copy(char* src, char* dst, int len)
{
for(int i=0;i<len;i+=32){
__m256i
x0=_mm256_loadu_si256(src+i);
_mm256_storeu_si256(dst+i, x0);
}
}
// non-temporal запись _mm256_stream_si256
copy(char* src, char* dst, int len)
{
for(int i=0;i<len;i+=32){
__m256i
x0=_mm256_loadu_si256(src+i);
_mm256_stream_si256(dst+i, x0);
}
}
Важно отметить, что инструкция _mm256_stream_si256
работает только с выровненной памятью.
Если мы измерим производительность обеих версий кода, то увидим следующий график:
Объем данных у нас больше, чем помещается в память. Non-temporal код значительно лучше работает в ней, но в кэше работает медленнее, чем код «обычной» записи.
Совет: если вы точно знаете объем данных, к примеру он заведомо гораздо больше всех кэшей в CPU, то можете переключаться между storeu и stream. То есть записывать через кэш или же использовать non-temporal запись.
Рекомендую следующий алгоритм использования:
if( data_volume < cpu_cache_size )
storeu
else {
stream
Сравнение производительности с функцией memcpy
Мы можем также вызвать обычную системную memcpy
. В реализации функции memcpy
могут использоваться все перечисленные выше способы, а также специальные rep mov инструкции, которые могут неявно улучшаться в новых моделях процессоров.
Посмотрим на график:
График производительности memcpy
лежит посередине между векторизованным AVX512-кодом и non-temporal. Следовательно, memcpy
тоже хорошо оптимизирована, и можно использовать ее.
Влияние каналов памяти на копирование
Оптимизатору важно знать, сколько каналов памяти у процессора, с которым он работает.
Если мы возьмем какую-нибудь функцию копирования, то теоретически производительность этой функции должна быть 1/N, где N — количество потоков данных. Однако рано или поздно потоки данных упираются в количество каналов в памяти.
Такие memory bounded функции (то есть функции с небольшим количеством вычислений по сравнению с загрузками) перестают ускоряться на N потоках гораздо раньше, чем число доступных ядер в процессоре. Например, число ядер на Xeon достигает 24, 48 и более на один процессор, а каналов в памяти гораздо меньше — всего 6 или 8. Для функций вида «мало вычислений, много данных» пропускная способность памяти является ограничителем.
Покажу для примера график соотношения времени к числу потоков для процессора Skylake:
Ближайшее будущее: High Bandwidth Memory
Мы выяснили, что память до сих пор является ограничителем производительности. Чтобы решить эту проблему, индустрия рассматривает новый стандарт памяти — High Bandwidth Memory (HBM). Сейчас ширина шины памяти составляет 64 байта, а на HBM она достигнет 1024 байт.
Наиболее эффективно HBM будет работать в многопоточных приложениях. Пока процессоры с такой памятью еще не доступны пользователям, но уже можно почитать об успехах в разработке:
-
Wccftech: Intel Sapphire Rapids Xeon Platinum 8472C HBM на 32% быстрее, чем без HBM 8480H.
-
Ixbt.com: новый стандарт высокоскоростной памяти High Bandwidth Memory.
Краткий вывод о системе кэша и памяти
Чтобы оптимизировать код копирования байтового массива данных, можно:
-
Использовать векторные инструкции.
-
Выровнять все массивы на 64 байта.
-
Использовать non-temporal запись.
-
Использовать функцию
memcpy
для сравнения.
Все эти приемы могут дать ощутимый прирост к скорости выполнения задачи. При этом стоит помнить про ограничения, которые накладывает количество каналов памяти в вашем процессоре.
Использование инструкций AVX-512
AVX-512 — это расширение архитектуры x86, которое позволяет использовать векторные инструкции с длиной 512 бит. Давайте посмотрим, чем такие инструкции могут быть полезны при оптимизации.
Табличная замена
Частый прием оптимизации — это замена одного байта другим — например, для:
-
подсчета числа ненулевых бит в байте,
-
поиска первого/последнего ненулевого бита/байта,
-
перестановки бит.
То есть вместо сложных вычислений можно просто встроить таблицу замены в код программы для всех 256 возможных значений байта.
Допустим, у нас есть какие-нибудь вычисления и нам нужно узнать, какой первый ненулевой бит в байте. Алгоритм перестановки младшей и старшей половинок байта выглядит так:
Как реализовать замену байт? Каждый байт имеет всего 2^8 = 256 возможных значений. Создадим специальную lookup-таблицу (LUT). Она позволит получить те самые 256 значений:
// создание таблицы
char tbl[256];
make_tbl()
{
for(i=0;i<256;i++){
tbl[i]=
(i<<4)|(i>>4);
}
}
И теперь заменим входной массив нашей таблицы:
// замена по таблице
lut_c(uchar* src, uchar* dst, int len)
{
for(i=0;i<len;i++)
dst[i] = tbl[ src[i] ];
}
Здесь:
-
dst[i]
— это значение в таблице, -
src[i]
— входной элемент, который будет индексом в таблице.
Этот код и называется «табличная замена», или lookup table (LUT).
Байтовая перестановка 16 байт
Много лет назад в процессоре Core 2 Duo появилась инструкция _mm_shuffle_epi8
, которая позволяет реализовать LUT для 16 значений байт. Вот как выглядит ее применение:
__m128i _mm_shuffle_epi8 (__m128i a, __m128i b)
Мы загружаем в векторный регистр a
значения, в b
загружаются позиции, из которых нужно взять элементы в a
. В с
получаем результат:
Если мы используем такую инструкцию, то можем заменить 16 байт. Но этого недостаточно для полноценной байтовой замены.
Байтовая перестановка 128 байт
В наборе VBMI (Vector Bit Manipulation Instructions) появилась новая инструкция байтовой перестановки. Она использует два регистра по 512 бит, то есть два по 64 байта, и сквозную индексацию:
__m512i _mm512_permutex2var_epi8 (__m512i a, __m512i idx, __m512i b)
Здесь:
-
idx
— индекс. -
a
,b
— регистры.
Такая инструкция позволяет реализовать LUT для 128 байт — от 0 до 127:
Если нам нужно заменить всего 128 значений, мы можем загрузить посчитанную таблицу и вызвать нашу инструкцию байтовой замены:
// LUT на 128 значений
uchar tbl[128]
...
t0 = _mm512_load(tbl )
t1 = _mm512_load(tbl+64)
x = _mm512_permutex2var_epi8 (t0, x, t1)
Такой код позволяет правильно заменить по таблице все байты в регистре x
, при условии что все x[i]<128. Старший бит в инструкции не используется.
Байтовая перестановка 256 байт
Как тогда заменить всю таблицу длиной 256 байт?
// LUT на все 256 значений
t0 = mm512_load (tbl )
t1 = mm512_load (tbl + 64)
t2 = mm512_load (tbl +128)
t3 = mm512_load (tbl +192)
m = mm512_cmpgt (x, 127)
a = mm512_permute (t0, x, t1)
b = mm512_permute (t2, x, t3)
x = _mm512_blend (m, a, b);
В t0
, t1
, t2
, t3
мы полностью загружаем всю таблицу, все 256 значений. Далее в битовой маске m
сравниваем векторные значения с числом 127 и делаем две перестановки в зависимости от диапазона, где лежит значение. Одна из них будет правильной. Инструкция blend
позволяет получить правильное значение в x
, используя битовую маску в m
.
Если перевести на язык математики, то:
m: m[i]=0, если x[i] <= 127
m[i]=1, если x[i] > 127, i=0..64
t0 и t1: корректная замена для x[i] <= 127
t2 и t3: для x[i] > 127
blend: x[i]=a[i] если m[i]=0
x[i]=b[i] если m[i]=1
В x
— корректная замена байта на байт для всех 256 возможных значений.
Так можно полностью заменить весь вектор. Подход очень полезен в различных алгоритмах, где нужно один байт заменить на другой.
Сравнение производительности кода
Вот наш изначальный код табличной замены на С:
// С-код
char tbl[256];
make_tbl(){
for(i=0;i<256;i++){
tbl[i]=
(i<<4)|(i>>4);
}
}
lut_c(uchar* src, uchar* dst, int len){
for(i=0;i<len;i++)
dst[i] = tbl[src[i]];
}
И вот как он стал выглядеть с применением инструкций AVX-512:
// векторизованный avx512
lut_vec(uchar* src, uchar* dst, int len){
__m512i x0, x1, x2, x3, t0, t1, t2, t3;
__mmask64 m;
t0 = _mm512_loadu_si512(tbl+0*64);
t1 = _mm512_loadu_si512(tbl+1*64);
t2 = _mm512_loadu_si512(tbl+2*64);
t3 = _mm512_loadu_si512(tbl+3*64);
for(i=0;i<(len&(~63));i+=64){
x0=_mm512_loadu_si512(src+i);
x1=_mm512_permutex2var_epi8 (t0, x0, t1);
x2=_mm512_permutex2var_epi8 (t2, x0, t3);
m =_mm512_cmpgt_epu8_mask (x0, _mm512_set1_epi8(127));
x3=_mm512_mask_blend_epi8 (m, x1, x2);
_mm512_storeu_si512(dst+i,x3);
}
for(;i<len;i++)
dst[i] = tbl[src[i]];
}
Запускаем на ноутбуке (Сore i5-1135G7, 2.4 ГГц, 1024 байта) и получаем следующие результаты:
-
C-код: 619 мс.
-
AVX512-код: 34 мс.
Ускорение: 619 мс / 34 мс = 18 раз.
Масочные регистры в AVX-512
Все массивы данных обрабатываются по 16 элементов. Но что если нам нужно только 9? Для решения таких задач можно использовать масочные регистры, которые появились в AVX-512:
__m512 _mm512_mask_add_ps (__m512 src, __mmask16 k, __m512 a, __m512 b)
Здесь:
-
__m512 src
,__m512 a
,__m512 b
— это регистры. -
__mmask16 k
— это битовая маска.
Вот схема того, как работают инструкции с масками:
У нас есть регистр a
и регистр b
. В каждом из них — свои значения. Мы выполняем операцию сложения add.
Посмотрим для примера на второй столбец справа. Мы складываем 1.1 и 2.1, получаем в промежуточном итоге операции tmp
число 3.2.
Мы использовали бы значения из tmp
, если бы у нас не было масочного регистра k
. Но он есть. Битовая маска накладывается на значение промежуточного результата операции. 1 означает, что полученный результат — тот, что нам нужен. Но если в масочном регистре 0, то мы берем не результат сложения, а берем значение из src
. В нашем случае правильным результатом будет не 3.2, а 5.0.
Давайте посмотрим, как использовать масочные регистры на практике и какое ускорение они могут нам дать.
Сравнение производительности кода
Возьмем наш AVX512-код из прошлого примера:
//avx512 + «С код остатка»
t0 = _mm512_loadu_si512(tbl)
t1 = _mm512_loadu_si512(tbl+64)
t2 = _mm512_loadu_si512(tbl+128)
t3 = _mm512_loadu_si512(tbl+192)
for(i=0;i<len;i+=64){
x0=_mm512_loadu_si512(src+i)
x1=_mm512_permutex2var_epi8 (t0, x0, t1)
x2=_mm512_permutex2var_epi8 (t2, x0, t3)
m =_mm512_cmpgt_epu8_mask (x0, 127)
x3=_mm512_mask_blend_epi8 (m, x1, x2)
_mm512_storeu_si512(dst+i,x3)
}
for(;i<len;i++)
dst[i] = tbl[src[i]];
}
Представим, что пользователь подал вектор длиной 1087 элементов. Наши итерации в AVX-части — по 64 байта, следовательно: 1087 = 16 × 64 + 63.
Мы выполним основные 16 итераций по 64 байта, а «хвост», то есть последние элементы, мы будем обрабатывать за оставшиеся 63 итерации в цикле на С.
Давайте попробуем улучшить ситуацию и добавим инструкции с масками. Основной код остается, а дальше мы загружаем последние элементы через битовую маску:
//avx512 + «маски для остатка»
t0 = _mm512_loadu_si512(tbl)
t1 = _mm512_loadu_si512(tbl+64)
t2 = _mm512_loadu_si512(tbl+128)
t3 = _mm512_loadu_si512(tbl+192)
for(i=0;i<len;i+=64){
x0=_mm512_loadu_si512(src+i)
x1=_mm512_permutex2var_epi8 (t0, x0, t1)
x2=_mm512_permutex2var_epi8 (t2, x0, t3)
m =_mm512_cmpgt_epu8_mask (x0,127)
x3=_mm512_mask_blend_epi8 (m, x1, x2)
_mm512_storeu_si512(dst+i,x3)
}
int tail=len-i;
__mmask64 mtail=(0xFFFFFFFFFFFFFFFFU>>(64 - tail))
if(tail>0){
x0 = _mm512_maskz_loadu_epi8(mtail, src+i);
x1 = _mm512_permutex2var_epi8 (t0, x0, t1)
x2 = _mm512_permutex2var_epi8 (t2, x0, t3)
m = _mm512_cmpgt_epu8_mask (x0,127)
x3 = _mm512_mask_blend_epi8 (m, x1, x2)
_mm512_mask_storeu_epi8(dst+i, mtail, x3)
}
Запустим оба решения на ноутбуке с процессором Сore i5-1135G7, 2.4 ГГц и сравним результаты.
AVX512 + «C код остатка»:
len=1024: 34 мс
len=1087: 52 мс
AVX512 + «маски для остатка»:
len=1024: 34 мс
len=1087: 36 мс
Ускорение: 52 мс / 36 мс = 1.44 раза.
Время работы функции для вектора длиной 1087 элементов составляло 52 миллисекунды, а благодаря масочным регистрам оно сократилось до 36 миллисекунд. То есть без применения битовой маски мы могли потерять 40% производительности.
Краткий вывод об инструкциях AVX-512
Использование инструкций AVX-512 позволяет значительно ускорить выполнение кода:
-
Можно сделать байтовую перестановку на все 256 бит в 18 раз быстрее, чем с обычным кодом на C.
-
Масочные регистры позволяют быстрее обрабатывать число элементов, не кратное 16.
Конечно, инструкции AVX-512 подходят не только для ускорения байтовых перестановок. Инструкций великое множество, и каждая может быть полезна оптимизатору в разных ситуациях.
Полезные ссылки
Напоследок поделюсь тремя ссылками, которые могут быть полезны оптимизатору. Все материалы — на английском:
-
Как и когда использовать инструкции AVX-512.
-
Онлайн-справочник со списком интринсиков.
-
Подробное описание микроархитектур процессоров.
Автор: Андрей