программы

Как оптимизировать код на С для x86-процессоров: подсистема кэша и памяти, инструкции AVX-512

Изображение создано
с помощью нейросети
программы
1403
0
21 февраля 2024
Как оптимизировать код на С для x86-процессоров: подсистема кэша и памяти, инструкции AVX-512
Изображение создано с помощью нейросети
1403
0
21 февраля 2024

Андрей Бакшаев занимается разработкой и оптимизацией математических библиотек под архитектуру 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-битный регистр.
Интринсики (intrinsics) — упрощенно, это специального вида встроенные функции, позволяющие вызывать машинные инструкции непосредственно из языка C, в том числе и векторные.

Мы подключаем векторное расширение и немного модифицируем наш скалярный код функции 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, тем быстрее.

Сравнение производительности кода: скалярный С, векторизованные AVX2 и AVX-512
По вертикали — такты на элемент вектора. По горизонтали — длина копируемых данных

Посмотрим детально, что происходит в L1-, L2-, L3-кэше и памяти:

L1-, L2-, L3-кэш и память
L1-, L2-, L3-кэш и память
  1. Объем L1 в нашем случае — 48 Кб. Данные помещаются сюда до длины ~48 Кб/2 = 24 Кб. AVX512-код обгоняет AVX2 почти в два раза.
  2. Объем данных превышает размер L1, но не превышает размер L2 примерно до длины 1,25 Мб/2 = 625 Кб. Здесь производительность сравнялась.
  3. До длины 4096 Кб данные помещаются в L3. Производительность всех трех версий кода снова на равных.
  4. Объем данных превышает суммарный размер кэша, и данные уходят в память. Производительность очень сильно снижается.

Выравнивание массива данных

На производительность кода могут влиять не только длины векторов, но и то, по каким адресам в памяти эти векторы расположены. А точнее то, насколько адреса векторов смещены относительно адреса, кратного 64.

Давайте измерим все смещения входного массива относительно такого адреса. Сначала выделим выровненную на 64 байта память с достаточным запасом. А далее будем измерять время копирования в таком цикле, смещаясь на 1 байт.

Получаем такой график:

Время копирования элемента на невыровненных данных входного массива 0…63 байта
Время копирования элемента на невыровненных данных входного массива 0…63 байта

Сначала 512-битный код показывает себя лучше всех, затем его производительность снижается и восстанавливается, только когда прошло смещение на 64 байта.

Почему так? Для AVX-512 любое чтение по адресу, не кратному 64-м, затрагивает две кэш-линии (cache split). Это происходит потому, что длина кэш-линии — 64 байта (512 бит) и регистр в нашем AVX512-коде — 64 байта. А читать две кэш-линии дольше, чем одну.

Рекомендуется выравнивать все массивы в приложениях на 64 байта. Если выровнять можно лишь один массив из двух, то смещенное чтение данных предпочтительнее записи.

Non-temporal инструкции

Иногда для повышения производительности мы можем заменить инструкции с обычного чтения на non-temporal чтение. Этот термин означает, что все данные будут записываться напрямую в память и не будут попадать в кэш.

Важно отметить, что инструкция _mm256_stream_si256 работает только с выровненной памятью.

Если мы измерим производительность обеих версий кода, то увидим следующий график:

Сравнение обычной copy и non-temporal кода
Время записи элементов в зависимости от длины вектора данных. Синий — обычное copy (например, AVX-512), голубой — non-temporal код

Объем данных у нас больше, чем помещается в память. Non-temporal код значительно лучше работает в ней, но в кэше работает медленнее, чем код «обычной» записи.

Если вы точно знаете объем данных, к примеру он заведомо гораздо больше всех кэшей в CPU, то можете переключаться между storeu и stream. То есть записывать через кэш или же использовать non-temporal запись.

Рекомендую следующий алгоритм использования:

Сравнение производительности с функцией memcpy

Мы можем также вызвать обычную системную memcpy. В реализации функции memcpy могут использоваться все перечисленные выше способы, а также специальные rep mov инструкции, которые могут неявно улучшаться в новых моделях процессоров.

Посмотрим на график:

Время записи элементов в зависимости от длины вектора данных для _storeu / stream / memcpu
Время записи элементов в зависимости от длины вектора данных

График производительности memcpy лежит посередине между векторизованным AVX512-кодом и non-temporal. Следовательно, memcpy тоже хорошо оптимизирована, и можно использовать ее.

Влияние каналов памяти на копирование

Оптимизатору важно знать, сколько каналов памяти у процессора, с которым он работает.

Если мы возьмем какую-нибудь функцию копирования, то теоретически производительность этой функции должна быть 1/N, где N — количество потоков данных. Однако рано или поздно потоки данных упираются в количество каналов в памяти.

Такие memory bounded функции (то есть функции с небольшим количеством вычислений по сравнению с загрузками) перестают ускоряться на N потоках гораздо раньше, чем число доступных ядер в процессоре. Например, число ядер на Xeon достигает 24, 48 и более на один процессор, а каналов в памяти гораздо меньше — всего 6 или 8. Для функций вида «мало вычислений, много данных» пропускная способность памяти является ограничителем.

Покажу для примера график соотношения времени к числу потоков для процессора Skylake:

Cоотношения времени к числу потоков для процессора Skylake
Cоотношения времени к числу потоков для процессора Skylake

Ближайшее будущее: High Bandwidth Memory

Мы выяснили, что память до сих пор является ограничителем производительности. Чтобы решить эту проблему, индустрия рассматривает новый стандарт памяти — High Bandwidth Memory (HBM). Сейчас ширина шины памяти составляет 64 байта, а на HBM она достигнет 1024 байтов.

Схема High Bandwidth Memory
Interposer — кремниевая подложка, способная вместить соединения большой плотности для организации широкой шины памяти.
Источник: Forbes.com

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

  • Wccftech
    Intel Sapphire Rapids Xeon Platinum 8472C HBM на 32% быстрее, чем без HBM 8480H.
  • Ixbt.com
    Новый стандарт высокоскоростной памяти High Bandwidth Memory.

Краткий вывод о системе кэша и памяти

Чтобы оптимизировать код копирования байтового массива данных, можно:

  1. Использовать векторные инструкции.
  2. Выровнять все массивы на 64 байта.
  3. Использовать non-temporal запись.
  4. Использовать функцию memcpy для сравнения.

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

Использование инструкций AVX-512

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

Табличная замена

Частый прием оптимизации — это замена одного байта другим — например, для:

  • подсчета числа ненулевых бит в байте,
  • поиска первого/последнего ненулевого бита/байта,
  • перестановки бит.

То есть вместо сложных вычислений можно просто встроить таблицу замены в код программы для всех 256 возможных значений байта.

Допустим, у нас есть какие-нибудь вычисления и нам нужно узнать, какой первый ненулевой бит в байте. Алгоритм перестановки младшей и старшей половинок байта выглядит так:

Алгоритм перестановки младшей и старшей половинок байта
Байт 56 будет заменен на 65, 34 — на 43

Как реализовать замену байтов? Каждый байт имеет всего 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 байтов

Если мы используем такую инструкцию, то можем заменить 16 байтов. Но этого недостаточно для полноценной байтовой замены.

Байтовая перестановка 128 байтов

В наборе VBMI (Vector Bit Manipulation Instructions) появилась новая инструкция байтовой перестановки. Она использует два регистра по 512 бит, то есть два по 64 байта, и сквозную индексацию:

Здесь:

  • idx — индекс.
  • a, b — регистры.

Такая инструкция позволяет реализовать LUT для 128 байтов — от 0 до 127:

Реализация LUT для 128 байтов
Например, если индекс 2, то мы загружаем из регистра 0xAA

Если нам нужно заменить всего 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 байта) и получаем следующие результаты:

  1. C-код: 619 мс.
  2. AVX512-код: 34 мс.
Ускорение: 619 мс / 34 мс = 18 раз.

Масочные регистры в AVX-512

Все массивы данных обрабатываются по 16 элементов. Но что если нам нужно только 9? Для решения таких задач можно использовать масочные регистры, которые появились в AVX-512:

Здесь:

  • __m512 src, __m512 a, __m512 b — это регистры.
  • __mmask16 k — это битовая маска.

Вот схема того, как работают инструкции с масками:

Схема работы инструкций с масками в AVX-512
Схема работы инструкций с масками

У нас есть регистр 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 мс
Ускорение: 52 мс / 36 мс = 1.44 раза.

Время работы функции для вектора длиной 1087 элементов составляло 52 миллисекунды, а благодаря масочным регистрам оно сократилось до 36 миллисекунд. То есть без применения битовой маски мы могли потерять 40% производительности.

Краткий вывод об инструкциях AVX-512

Использование инструкций AVX-512 позволяет значительно ускорить выполнение кода:

  1. Можно сделать байтовую перестановку на все 256 бит в 18 раз быстрее, чем с обычным кодом на C.
  2. Масочные регистры позволяют быстрее обрабатывать число элементов, не кратное 16.

Конечно, инструкции AVX-512 подходят не только для ускорения байтовых перестановок. Инструкций великое множество, и каждая может быть полезна оптимизатору в разных ситуациях.

Полезные ссылки

Напоследок поделюсь тремя ссылками, которые могут быть полезны оптимизатору. Все материалы — на английском:

  1. Как и когда использовать инструкции AVX-512.
  2. Онлайн-справочник со списком интринсиков.
  3. Подробное описание микроархитектур процессоров.
Наверх
Будь первым, кто оставит комментарий