Представим, что перед нами стоит типовая задача — скопировать массив байтов. Начнем с обычного кода на С и постараемся постепенно модифицировать его, повышая производительность.
Как оптимизировать код на С для x86-процессоров: подсистема кэша и памяти, инструкции AVX-512
с помощью нейросети
Андрей Бакшаев занимается разработкой и оптимизацией математических библиотек под архитектуру x86 в YADRO. До этого он 15 лет работал в Intel. Значительная часть его задач заключалась в том, чтобы реализовывать некоторые алгоритмы обработки изображений и сигналов в довольно известной математической библиотеке IPP, максимально эффективно используя возможности процессоров. Он также исследовал производительность этих алгоритмов в процессорах на ранней стадии проектирования.
В статье Андрей делится своим опытом оптимизации низкоуровневого кода на языке C. Рассмотрим подсистему кэша и памяти процессоров и новые инструкции AVX-512. Разберем пример ускорения копирования байтового массива данных и посмотрим, как векторизованный код позволяет сократить время работы широко используемого алгоритма замены байтов по таблице с 619 до 34 мс, то есть примерно в 18 раз.
Подсистема кэша и памяти
Скалярный и векторизованный код
Реализуем простую функцию copy:
Чем отличается скалярный код от векторизованного? Исполняемые арифметические инструкции в процессоре можно поделить на две категории:
Современные компиляторы поддерживают автовекторизацию для кода на языке С, что сильно упрощает процесс оптимизации.
Первое векторное расширение, появившееся в x86, называлось MMX (Multimedia Extension). Регистры были 64 бит, и одновременно обрабатывалось по 64/8=8 байтов.
Далее появилось расширение SSE (Streaming SIMD Extensions) с векторами по 128 бит (16 байтов). На данный момент актуальны расширения AVX2 и AVX-512 с векторами по 256 и 512 бит.
Посмотрим на примере, как выглядит векторизованный код:
Здесь:
- immintrin.h — файл с интринсиками языка C.
- x0 — объявление и использование векторного регистра.
- __m256i x0 — переменная, соответствующая 256-битному векторному регистру.
- _mm256_loadu_si256 — интринсик загрузки из памяти в 256-битный регистр.
Мы подключаем векторное расширение и немного модифицируем наш скалярный код функции copy. В цикле i++ заменяем на i+=32, поскольку теперь за одну итерацию одной командой loadu мы загружаем 32 байта из памяти. Команда storeu записывает данные в память. Такой код называется AVX2 векторизованным, поскольку он использует 256-битные регистры расширения AVX2.
Можно написать и AVX-512 векторизованный код, который использует 512-битный регистр. Это позволит брать на одной итерации в 2 раза больше данных, по 64 байта:
Векторные интринсики практически соответствуют процессорным инструкциям и позволяют легко разрабатывать векторизованный код на языке C. Машинный код будет очень похожим.
Сравнение производительности кода
Мы написали три разных варианта кода для копирования байтового массива данных: скалярный С и векторизованные AVX2, AVX-512. Давайте сравним их между собой по производительности.
Поскольку входной и выходной векторы имеют одинаковую длину, в кэш будет попадать суммарный объем данных, равный удвоенной длине, то есть 2 x длина вектора. Ее и нужно учитывать при изучении эффектов, связанных с кэшем. Посмотрим на график ниже. В нем используется единица измерения CPE (clock per element). Такты, затраченные на копирование вектора, делятся на его длину: CPE = clocks / length. Чем меньше CPE, тем быстрее.
Посмотрим детально, что происходит в L1-, L2-, L3-кэше и памяти:
- Объем L1 в нашем случае — 48 Кб. Данные помещаются сюда до длины ~48 Кб/2 = 24 Кб. AVX512-код обгоняет AVX2 почти в два раза.
- Объем данных превышает размер L1, но не превышает размер L2 примерно до длины 1,25 Мб/2 = 625 Кб. Здесь производительность сравнялась.
- До длины 4096 Кб данные помещаются в L3. Производительность всех трех версий кода снова на равных.
- Объем данных превышает суммарный размер кэша, и данные уходят в память. Производительность очень сильно снижается.
Выравнивание массива данных
На производительность кода могут влиять не только длины векторов, но и то, по каким адресам в памяти эти векторы расположены. А точнее то, насколько адреса векторов смещены относительно адреса, кратного 64.
Давайте измерим все смещения входного массива относительно такого адреса. Сначала выделим выровненную на 64 байта память с достаточным запасом. А далее будем измерять время копирования в таком цикле, смещаясь на 1 байт.
Получаем такой график:
Сначала 512-битный код показывает себя лучше всех, затем его производительность снижается и восстанавливается, только когда прошло смещение на 64 байта.
Почему так? Для AVX-512 любое чтение по адресу, не кратному 64-м, затрагивает две кэш-линии (cache split). Это происходит потому, что длина кэш-линии — 64 байта (512 бит) и регистр в нашем AVX512-коде — 64 байта. А читать две кэш-линии дольше, чем одну.
Non-temporal инструкции
Иногда для повышения производительности мы можем заменить инструкции с обычного чтения на non-temporal чтение. Этот термин означает, что все данные будут записываться напрямую в память и не будут попадать в кэш.
Важно отметить, что инструкция _mm256_stream_si256 работает только с выровненной памятью.
Если мы измерим производительность обеих версий кода, то увидим следующий график:
Объем данных у нас больше, чем помещается в память. Non-temporal код значительно лучше работает в ней, но в кэше работает медленнее, чем код «обычной» записи.
Рекомендую следующий алгоритм использования:
Сравнение производительности с функцией 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 будет работать в многопоточных приложениях. Пока процессоры с такой памятью еще не доступны пользователям, но уже можно почитать об успехах в разработке:
Краткий вывод о системе кэша и памяти
Чтобы оптимизировать код копирования байтового массива данных, можно:
- Использовать векторные инструкции.
- Выровнять все массивы на 64 байта.
- Использовать non-temporal запись.
- Использовать функцию memcpy для сравнения.
Все эти приемы могут дать ощутимый прирост к скорости выполнения задачи. При этом стоит помнить про ограничения, которые накладывает количество каналов памяти в вашем процессоре.
Использование инструкций AVX-512
AVX-512 — это расширение архитектуры x86, которое позволяет использовать векторные инструкции с длиной 512 бит. Давайте посмотрим, чем такие инструкции могут быть полезны при оптимизации.
Табличная замена
Частый прием оптимизации — это замена одного байта другим — например, для:
- подсчета числа ненулевых бит в байте,
- поиска первого/последнего ненулевого бита/байта,
- перестановки бит.
То есть вместо сложных вычислений можно просто встроить таблицу замены в код программы для всех 256 возможных значений байта.
Допустим, у нас есть какие-нибудь вычисления и нам нужно узнать, какой первый ненулевой бит в байте. Алгоритм перестановки младшей и старшей половинок байта выглядит так:
Как реализовать замену байтов? Каждый байт имеет всего 28 = 256 возможных значений. Создадим специальную lookup-таблицу (LUT). Она позволит получить те самые 256 значений:
И теперь заменим входной массив нашей таблицы:
Здесь:
- dst[i] — это значение в таблице,
- src[i] — входной элемент, который будет индексом в таблице.
Этот код и называется «табличная замена», или lookup table (LUT).
Байтовая перестановка 16 байтов
Много лет назад в процессоре Core 2 Duo появилась инструкция _mm_shuffle_epi8, которая позволяет реализовать LUT для 16 значений байтов. Вот как выглядит ее применение:
Мы загружаем в векторный регистр a значения, в b загружаются позиции, из которых нужно взять элементы в a. В с получаем результат:
Если мы используем такую инструкцию, то можем заменить 16 байтов. Но этого недостаточно для полноценной байтовой замены.
Байтовая перестановка 128 байтов
В наборе VBMI (Vector Bit Manipulation Instructions) появилась новая инструкция байтовой перестановки. Она использует два регистра по 512 бит, то есть два по 64 байта, и сквозную индексацию:
Здесь:
- idx — индекс.
- a, b — регистры.
Такая инструкция позволяет реализовать LUT для 128 байтов — от 0 до 127:
Если нам нужно заменить всего 128 значений, мы можем загрузить посчитанную таблицу и вызвать нашу инструкцию байтовой замены:
Такой код позволяет правильно заменить по таблице все байты в регистре x при условии, что все x[i]<128. Старший бит в инструкции не используется.
Байтовая перестановка 256 байтов
Как тогда заменить всю таблицу длиной 256 байтов?
В t0, t1, t2, t3 мы полностью загружаем всю таблицу, все 256 значений. Далее в битовой маске m сравниваем векторные значения с числом 127 и делаем две перестановки в зависимости от диапазона, где лежит значение. Одна из них будет правильной. Инструкция blend позволяет получить правильное значение в x, используя битовую маску в m.
Если перевести на язык математики, то:
Так можно полностью заменить весь вектор. Подход очень полезен в различных алгоритмах, где нужно один байт заменить на другой.
Сравнение производительности кода
Вот наш изначальный код табличной замены на С:
И вот как он стал выглядеть с применением инструкций AVX-512:
Запускаем на ноутбуке (Сore i5−1135G7, 2.4 ГГц, 1024 байта) и получаем следующие результаты:
- C-код: 619 мс.
- AVX512-код: 34 мс.
Масочные регистры в AVX-512
Все массивы данных обрабатываются по 16 элементов. Но что если нам нужно только 9? Для решения таких задач можно использовать масочные регистры, которые появились в AVX-512:
Здесь:
- __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-код из прошлого примера:
Представим, что пользователь подал вектор длиной 1087 элементов. Наши итерации в AVX-части — по 64 байта, следовательно: 1087 = 16×64 + 63.
Мы выполним основные 16 итераций по 64 байта, а «хвост», то есть последние элементы, мы будем обрабатывать за оставшиеся 63 итерации в цикле на С.
Давайте попробуем улучшить ситуацию и добавим инструкции с масками. Основной код остается, а дальше мы загружаем последние элементы через битовую маску:
Запустим оба решения на ноутбуке с процессором Сore i5−1135G7, 2.4 ГГц и сравним результаты.
AVX512 + «C код остатка»:
- len=1024: 34 мс
- len=1087: 52 мс
AVX512 + «маски для остатка»:
- len=1024: 34 мс
- len=1087: 36 мс
Время работы функции для вектора длиной 1087 элементов составляло 52 миллисекунды, а благодаря масочным регистрам оно сократилось до 36 миллисекунд. То есть без применения битовой маски мы могли потерять 40% производительности.
Краткий вывод об инструкциях AVX-512
Использование инструкций AVX-512 позволяет значительно ускорить выполнение кода:
- Можно сделать байтовую перестановку на все 256 бит в 18 раз быстрее, чем с обычным кодом на C.
- Масочные регистры позволяют быстрее обрабатывать число элементов, не кратное 16.
Конечно, инструкции AVX-512 подходят не только для ускорения байтовых перестановок. Инструкций великое множество, и каждая может быть полезна оптимизатору в разных ситуациях.
Полезные ссылки
Напоследок поделюсь тремя ссылками, которые могут быть полезны оптимизатору. Все материалы — на английском:
- Как и когда использовать инструкции AVX-512.
- Онлайн-справочник со списком интринсиков.
- Подробное описание микроархитектур процессоров.