суббота, 25 января 2020 г.

AVX512-VNNI -- Синтез алгоритма для сверточной нейронной сети

Необходимо сделать самый быстрый алгоритм для свертки (convolution). На свертках делается современное представление о нейронных сетях, так и называется сверточные нейронные сети (CNN). Производители процессоров заявляют: мы добавили искусственный интеллект (AI) в процессоры. По сути это означает только одно - увеличили длину вектора для операции умножения. Операции свертки используются в цифровых фильтрах обработки сигналов(DSP) и в задачах связанных с обработкой звука и изображения. Есть задачи выделения контуров изображений и задачи выделения движущихся объектов на изображении. Есть задачи увеличения четкости изображения, пересчет цветности. Все подобные задачи цифровой обработки изображений требуют матричного умножения NxN элементов. При этом коэффициенты могут быть заданы числами со знаком 8бит. А значения над которыми производится операция - 8 бит без знака. Под этот класс задач компания Intel разработала систему команд AXV512-VNNI. В систему команд VNNI входит всего две инструкции. Одна из них производит покомпонентное умножение 8битных целых без знака на 8битные целые со знаком и последующее сложение с накоплением в 32 битном приемнике.
Математически выражается как-то так. Знаковые и беззнаковые компоненты перемножаются и суммируются группами по четыре, чтобы не нарушать разрядность вектора. При большом количестве таких операций может возникать переполнение разрядов. Для нейронных сетей абсолютно все выводы классификации делаются на переполнении, если есть переполнение то это и означает переход от шаманства с бубнами к логике. В данном случае операция насыщения поверх сложения не сильно оправдана. Надо выполнить более 16 тыс таких операций, чтобы сложились условия для переполнения.
VPDPBUSDS — Multiply and Add Unsigned and Signed Bytes with Saturation
p1 = ZERO_EXTEND(a.byte[4*i+0]) * SIGN_EXTEND(b.byte[4*i+0])
p2 = ZERO_EXTEND(a.byte[4*i+1]) * SIGN_EXTEND(b.byte[4*i+1])
p3 = ZERO_EXTEND(a.byte[4*i+2]) * SIGN_EXTEND(b.byte[4*i+2])
p4 = ZERO_EXTEND(a.byte[4*i+3]) * SIGN_EXTEND(b.byte[4*i+3])
DEST.dword[i] = SIGNED_DWORD_SATURATE(DEST.dword[i] + p1 + p2 + p3 + p4)
Так же выглядит вторая операция - умножение знаковых 16 битных чисел с накоплением результата в 32 битном приемнике.
VPDPWSSDS — Multiply and Add Signed Word Integers with Saturation
p1 = SIGN_EXTEND(a.word[2*i+0]) * SIGN_EXTEND(b.word[2*i+0])
p2 = SIGN_EXTEND(a.word[2*i+1]) * SIGN_EXTEND(b.word[2*i+1])
DEST.dword[i] = SIGNED_DWORD_SATURATE(DEST.dword[i] + p1 + p2)
Вот я это так и вижу. Первая операция - для умножения матриц, для обработки видео. Вторая - для умножения векторов, для обработки звука.
Я буду исследовать производительность Throughput & Latency(CPI - clock per instruction) с использованием LLVM-MCA, инструмента. Подбираю, как должен выглядеть алгоритм на языке высокого уровня, чтобы выжать из этих инструкций максимум производительности.
Под рукой у меня мануал на 5 тысяч страниц с описанием процессорных инструкций накопленных за всю историю Intel [Intel® 64 and IA-32 Architectures Software Developer’s Manual, Volume 2:Instruction Set Reference] и таблица производительности инструкций на ядрах 10th Gen Intel Core, с микро-архитектурой Ice Lake. Кроме того, практикую магию с использованием компиляторов GCC 9.2 и Clang 9.0. Вывожу результат компиляции в ассемблер и анализирую производительность выделенных фрагментов полученного кода инструментом LLVM-MCA, анализатором кода.

$ gcc -march=icelake-client -O3 -S -o nn.s nn.c
$ llvm-mca.exe -mcpu=icelake-client -timeline nn.s
Производительность заявленная в таблице - одна инструкция с разрядностью 512 бит на такт, т.е. 64 шт умножений. Пишу цикл свертки по вектору (по горизонтали изображения) и по вертикали.
#include <x86intrin.h>
static inline
__attribute__((__target__("avx512vnni")))
void mac_vec_512(__m512i *acc, uint8_t * src, const __m512i* weight, const size_t length)
{
    int x=0;
    for (; x<length/64;x++){
        __m512i w = _mm512_load_si512(&weight[x]);
        __m512i v = _mm512_loadu_si512(&src[x*64]);
        acc[0] = _mm512_dpbusd_epi32(acc[0], v,w);
    }
    if (length&63) {
        __mmask64 mask = (1ULL <<(length&63)) -1;
        __m512i w = _mm512_load_si512(&weight[x]);
        __m512i v = _mm512_maskz_loadu_epi8(mask, &src[x*64]);
        acc[0] = _mm512_dpbusd_epi32(acc[0], v,w);
    }
}
При разработке алгоритма я исхожу из положения - компилятор самостоятельно уберет из процедуры проверки констант и выполнит развертывание цикла. В предыдущей статье я уже объяснял основной прием работы с масками, повторяться не буду. Если размерность вектора не кратна размеру регистра, используется маска. В данном случае по маске выполняется загрузка вектора.
Ниже представлен результат анализа производительности цикла, временная диаграмма. Цикл разернут компилятором.

[0,0]     DeeeeeeeeER    .    .    .    .    vmovdqu32   (%rdx), %zmm2
[0,1]     D========eeeeeeeeeeeER   .    .    vpdpbusd    (%r8), %zmm2, %zmm0
[0,2]     D===================eER  .    .    vmovdqa64   %zmm0, (%rcx)
[0,3]     .DeeeeeeeeE-----------R  .    .    vmovdqu32   64(%rdx), %zmm3
[0,4]     .D===========eeeeeeeeeeeER    .    vpdpbusd    64(%r8), %zmm3, %zmm0
[0,5]     .D======================eER   .    vmovdqa64   %zmm0, (%rcx)
[0,6]     . DeeeeeeeeE--------------R   .    vmovdqu32   128(%rdx), %zmm4
[0,7]     . D==============eeeeeeeeeeeER.    vpdpbusd    128(%r8), %zmm4, %zmm0
[0,8]     . D=========================eER    vmovdqa64   %zmm0, (%rcx)
Видно, что ожидаемой производительности не получили, результат инструкции VPDBUSD выходит каждые четыре такта.
То что происходит дальше - шаманство с бубном. Я беру фрагмент ассемблерного кода и подбираю руками комбинацию инструкций, которая дает максимальную производительность. Я знаю что конвейер способен загружать из памяти два регистра одновременно.

[0,0]     DeeeeeeeeER    .    .    .    .    vmovdqu32  (%rdx), %zmm2
[0,1]     DeeeeeeeeER    .    .    .    .    vmovdqa64  (%r8), %zmm3
[0,2]     D========eeeeER.    .    .    .    vpdpbusd   %zmm3, %zmm2, %zmm0
[0,3]     .D===========eER    .    .    .    vmovdqa64  %zmm0, (%rcx)
[0,4]     .DeeeeeeeeE----R    .    .    .    vmovdqu32  64(%rdx), %zmm2
[0,5]     .DeeeeeeeeE----R    .    .    .    vmovdqa64  64(%r8), %zmm3
[0,6]     . D==========eeeeER .    .    .    vpdpbusd   %zmm3, %zmm2, %zmm0
[0,7]     . D==============eER.    .    .    vmovdqa64  %zmm0, (%rcx)
[0,8]     . DeeeeeeeeE-------R.    .    .    vmovdqu32  128(%rdx), %zmm2
[0,9]     .  DeeeeeeeeE------R.    .    .    vmovdqa64  128(%r8), %zmm3
[0,10]    .  D=============eeeeER  .    .    vpdpbusd   %zmm3, %zmm2, %zmm0
[0,11]    .  D=================eER .    .    vmovdqa64  %zmm0, (%rcx)
При этом производительность не изменилась, но существенно скоратилась задержка. Т.е. выигрыш будет на сравнительно небольших векторах. Видно что задержка в 4 такта определяется исключительно зависимостью от выходного результата в регистре ZMM0. В следующем приближении я уменьшаю зависимость от выходного регистра.
$ llvm-mca.exe -mcpu=icelake-client -timeline nn1.s
[0,0]     DeeeeeeeeER    .    .    .    .    vmovdqa64    (%r8), %zmm4
[0,1]     DeeeeeeeeER    .    .    .    .    movdqu32    (%rdx), %zmm5
[0,2]     D========eeeeER.    .    .    .    vpdpbusd     %zmm4, %zmm5, %zmm3
[0,3]     .DeeeeeeeeE---R.    .    .    .    vmovdqa64    64(%r8), %zmm4
[0,4]     .DeeeeeeeeE---R.    .    .    .    vmovdqu32    64(%rdx), %zmm5
[0,5]     .D========eeeeER    .    .    .    vpdpbusd     %zmm4, %zmm5, %zmm2
[0,6]     . DeeeeeeeeE---R    .    .    .    vmovdqa64    128(%r8), %zmm4
[0,7]     . DeeeeeeeeE---R    .    .    .    vmovdqu32    128(%rdx), %zmm5
[0,8]     . D========eeeeER   .    .    .    vpdpbusd     %zmm4, %zmm5, %zmm1
[0,9]     .  DeeeeeeeeE---R   .    .    .    vmovdqa64    192(%r8), %zmm4
[0,10]    .  DeeeeeeeeE---R   .    .    .    vmovdqu32    192(%rdx), %zmm5
[0,11]    .  D========eeeeER  .    .    .    vpdpbusd     %zmm4, %zmm5, %zmm0
Получил пропускную способность 64 умножения на такт. Результат операции выдается на каждый такт. В данном случае производительность ограничена пропускной способностью загрузки данных. А вот дальше происходит невозможное. Оказывается, производительность можно УДВОИТЬ, если коэффициенты для последующих умножений равны или загружены в регистры заранее. Вот пример такой оптимизации:
$ llvm-mca.exe -mcpu=icelake-client -timeline nn1.s
[0,0]     DeeeeeeeeER    .    .    vmovdqu32      (%rdx), %zmm5
[0,1]     D========eeeeER.    .    vpdpbusd       %zmm4, %zmm5, %zmm3
[0,2]     DeeeeeeeeE----R.    .    vmovdqu32      64(%rdx), %zmm5
[0,3]     D========eeeeER.    .    vpdpbusd       %zmm4, %zmm5, %zmm2
[0,4]     .DeeeeeeeeE---R.    .    vmovdqu32      128(%rdx), %zmm5
[0,5]     .D========eeeeER    .    vpdpbusd       %zmm4, %zmm5, %zmm1
[0,6]     .DeeeeeeeeE----R    .    vmovdqu32      192(%rdx), %zmm5
[0,7]     .D========eeeeER    .    vpdpbusd       %zmm4, %zmm5, %zmm0
# Еще один вариант оптимизации удвоенной производительностью
[0,0]     DeeeeeeeeeeeER .    .    vpdpbusd         (%rdx), %zmm9, %zmm0
[0,1]     DeeeeeeeeeeeER .    .    vpdpbusd       64(%rdx), %zmm9, %zmm1
[0,2]     D=eeeeeeeeeeeER.    .    vpdpbusd      128(%rdx), %zmm9, %zmm2
[0,3]     .DeeeeeeeeeeeER.    .    vpdpbusd      192(%rdx), %zmm9, %zmm3
[0,4]     .D=eeeeeeeeeeeER    .    vpdpbusd      256(%rdx), %zmm9, %zmm4
[0,5]     .D=eeeeeeeeeeeER    .    vpdpbusd      320(%rdx), %zmm9, %zmm5
[0,6]     . D=eeeeeeeeeeeER   .    vpdpbusd      384(%rdx), %zmm9, %zmm6
[0,7]     . D=eeeeeeeeeeeER   .    vpdpbusd      448(%rdx), %zmm9, %zmm7
Чтобы получить максимальную производительность надо добиться, чтобы выход компилятора выглядел именно так. Я оговорюсь. Результат может быть недостоверным. Надо проверять на целевой архитектуре. Следует отметить, что при оптимизации небольших циклов практически все векторные инструкции есть смысл отделять от инструкций загрузки регистров, так код получается быстрее, хотя и выглядит немного избыточным, у планировщика появляется больще возможностей загрузить конвейер неоднородными операциями.
#include <x86intrin.h>
static inline
__attribute__((__target__("avx512vnni")))
void mac_vec_512(__m512i *acc, uint8_t * src, const __m512i* weight, const size_t length)
{
    int x=0;
    for (; x<length/64;x++){
        __m512i v = _mm512_loadu_si512(&src[x*64]);
        acc[x&7] = _mm512_dpbusd_epi32(acc[x&7], v,weight[x]);
    }
    if (length&63) {
        __mmask64 mask = (1ULL <<(length&63)) -1;
        __m512i v = _mm512_maskz_loadu_epi8(mask, &src[x*64]);
        acc[x&7] = _mm512_dpbusd_epi32(acc[x&7], v, weight[x]);
    }
}
Тут мы используем восемь аккумуляторных регистров. Потом вектора аккумуляторов надо сложить между собой, выполнить горизонтальное суммирование. Такой красивый результат, как в предыдущей "ручной" опмтимизации, получить с помощью компилятора не удается. В большинстве случаев компилятор выдает что-то не эффективное. Сказывается еще одно аппаратное ограничение - тормозит декодер иснтрукций, у него своя производительность, ограниченная длиной буфера разбора. Не всегда этой длины хватает, чтобы обрабатывать четыре инсрукции в кодировке EVEX-512 на одном такте.
Наилучший результат без ручных оптимизаций __(впишу когда протестирую, хотя бы на симуляторе)__ умножений на такт. Теоретический предел 128 умножений на такт.

Нашел у Intel идею для описания подобной операции VDPDUSD там, где ее нет, на инструкциях AVX2. # VPMADDUBSW υ8×s8 → s16 multiples, VPMADDWD broadcast1 s16 → s32, and VPADDD s32 → s32.
// цикл на операциях AVX2
    for (x=0; x<length/32;x++){
        v = _mm256_loadu_si256((void*)&src[x*32]);
        v = _mm256_maddubs_epi16(v,weight[x]);
        v = _mm256_madd_epi16(v,E);
        acc= _mm256_add_epi32(acc, v);
    }
Для AVX2 компилятор выполняет оптимизацию сносно, алгоритм модифицировать не пришлось. Могут быть расхождения в результате, может возникать насыщение в операции VPMADDUBSW. В этом случае рекомендуется снижать разрядность весовых коэффициентов. Теоретический предел для AVX2 получается 32 умножения на такт, инструкции VPMADDUBSW и VPMADDWD имеют производительность две на такт, 0.5 CPI.

Эта статья об удвоении производительности. Сначала берем хорошую новую инструкцию в 2-4 раза более производительную чем раньше, получаем результат 1/4 от заявленной производительности. Потом начинаем шаманить и водить руками, получаем 100% - теоретический предел. Потом ударяем в бубен и получаем все 200%, о такой возможности производитель умолчал. Потом замеряем реальную производительность в цикле и получаем скромный результат всего __160%__ от заявленной в документации, что тоже не плохо. Еще остается методика оптимизации.

четверг, 23 января 2020 г.

Набор инструкций AVX-512 -- Переосмыслить всё!

-- переписать все алгоритмы на новую систему команд! - смысл такой.
Современные компиляторы никак не умнее программиста. Есть очень небольшая возможность оптимизировать циклы или ветвления за счет использования инструкций AVX-512. Одна из возможностей новой системы команд, на которую упирает разработчик (Intel) - возможность замены циклов и операций ветвления в циклах на инструкции с маской, это когда операция применяется не ко всему вектору, а к отдельным частям этого вектора. Вряд ли компилятор в ближайшие несколько лет научится полиморфить алгоритмы. Сейчас компиляторы работают по шаблону, шаблоны пишут люди, такие же программисты. Шаблонами все многообразие не описать. Изучая вторую неделю эффективность компиляторов версии 9.2+ понимаю, что они очень и очень далеки от искусственного интеллекта. В большинстве случаев мне почему то удается переписать код после оптимизации настолько, что он становится в два раза быстрее. Есть незначительное число вариантов циклов, которые поддаются оптимизации.
Краевые эффекты (хотел сказать "побочные"). Допустим мы можем эффективно умножать матрицы 8х8 элементов. Как умножать матрицы 7х7 элементов. Или 9х9 элементов. Вот тут на помощь приходит новая система команд с масками. Маска позволяет загрузить не кратное число элементов в безумно длинный векторный регистр. А вы знали, что векторная инструкция AVX-512 способна выдавать результат, сложения или умножения на каждом такте. Т.е. это буквально удваивает производительность, но только в том случае, когда алгоритм изначально можно параллелить. Если мы делаем из алгоритма AVX2 новый алгоритм увеличивая разрядность, такого прироста не наблюдается.
Копирование байтовых строк. Вот первая идея.

for(всех частей кратных векторному регистру){
  v = _mm512_loadu_si512(src); src+=64;
  _mm512_storeu_si512(m64, v)
}
if (есть остаток строки) {
// формируем маску
  __mmask64 mask = (~0ULL)>>(64-(len&63));
// загружаем строку произвольной длины меньше длины регистра
  __m512i v = _mm512_maskz_loadu_epi8(mask, src);
// сохраняем обработанную строку, фрагмент строки.
  _mm512_mask_storeu_epi8(dst, mask,v);
}

Для примера рассмотрим реализацию алгоритма BASE64. Чтобы не перегружать мозг, только основной цикл. Каждые 6 бит кодируются печатными буквами из словаря 64 символа. Кодировка применяется при передаче вложений в почтовые сообщения и веб-приложения. Короче, весь интернет забит этой кодировкой. Надо вам переслать скан договора или фотоархив, а он вот так вот кодируется BASE64 [RFC 4648].
// Реализация алгоритма кодирования BASE64
uint8_t* __attribute__((__target__("avx512vbmi")))
base64_enc_axv512(uint8_t *dst, uint8_t *src, int length)
{
const __m512i lookup = _mm512_loadu_si512(base64_table);
/* [a5..a0:b5b4][b3..b0:c5..c2][c1c0:d5..d0] -- расположение бит в памяти
   32-6,32-12,32-18,32-24 - сдвиги в младшем слове [31:0]
   64-6,64-12,64-18,64-24 - сдвиги в старшем слове [63:32]
   Вместе они формируют константу сдвигов.
*/
const __m512i shifts = _mm512_set1_epi64(0x282E343A080E141AULL);
const __m512i revert = (__m512i)(__v64qi){
    -1, 2, 1, 0, -1, 5, 4, 3, -1, 8, 7, 6, -1,11,10, 9,
    -1,14,13,12, -1,17,16,15, -1,20,19,18, -1,23,22,21,
    -1,26,25,24, -1,29,28,27, -1,32,31,30, -1,35,34,33,
    -1,38,37,36, -1,41,40,39, -1,44,43,42, -1,47,46,45
    };
__mmask64 mask = (1ULL<<48)-1;// маска 48 бит
    while (length>=48){// Производительность(CPI) 2 такта  на цикл 64 байта, ускорение 32 раза!!
        __m512i v = _mm512_maskz_loadu_epi8(mask, src);
        src+=48;
        v = _mm512_permutexvar_epi8 (revert, v);// переставить местами байты BSWAP32
        v = _mm512_multishift_epi64_epi8(shifts, v);// 32-6,32-12,32-18,32-24...
        v = _mm512_permutexvar_epi8(v, lookup);// игнорирует старшие 2 бита в байте.
        _mm512_storeu_si512(dst, v); dst+=64;
        length-=48;
    }
    . . .
}
Если реализация данного алгоритма вдохновляет, проследуйте по ссылке.. [0x80]. Для подготовки алгоритма использую справочник псевдо-функций по системе команд, стараюсь не списывать [Intel Intrinsics Guide].
Я понимаю, мир меняется очень серьезно. Компиляторы не дают ожидаемого эффекта, не поспевают за нововведениям Intel. Я вынужден использовать псевдо-функции (intrinsic).
Как вы понимаете, закон Мура в действии. Развитие идет по экспоненте, раньше частота удваивалась, потом удваивалась разрядность, потом ядреность, потом векторность, потом смысловая емкость системы команд. Что-то обязательно должно удваиваться, чтобы вы покупали все новые и новые гаджеты. Надо предлагать сразу несколько алгоритмов, потому что оптимизации подобного рода нужны на платформе ARM Neon, ARM Helium, Intel SSE, на AVX, на AVX2, AVX512 и так далее, каждые три года новая идея способная удвоить производительность. У программиста мозг не резиновый, программисты -- вымирающий вид. Сегодня это векторность в сочетании со смысловой емкостью системы команд.
Надо чтобы программа на этапе загрузки решала, какую оптимизацию использовать в зависимости от поддерживаемой системы команд. Многие куски кода, такие как функции кодирования, я повторно использую в проектах, могу иногда для разминки мозгов применять разные оптимизации. Эти оптимизации нужно оставлять в бестиариуме, прикомпиливать подходящие варианты.
Вернемся к обсуждению алгоритма. Данная реализация ускоряет в 32 раза процесс обработки, в сравнении с алгоритмом, который мне казался вполне быстрым, потому что тратил всего один такт на каждый байт. Данная реализация тратит два такта процессора на 64 байта. Попробую доказать. Ниже привожу результат анализа загрузки ресурсов ядра процессора Ice Lake.

Resource pressure by instruction:(нагрузка на вычислительные блоки)
[0]    [1]    [2]    [3]    [4]    [5]    [6]    [7]    [8]    [9]    Instructions:
 -      -      -      -     0.65   0.35    -     1.00    -      -     vpermb    (%rdx), %zmm2, %zmm0
 -      -      -     0.97    -      -      -      -     0.03    -     subl      $48, %r8d
 -      -     1.00    -      -      -      -      -      -      -     vpmultishiftqb    %zmm0, %zmm3, %zmm0
 -      -      -      -      -      -      -     1.00    -      -     vpermb    %zmm1, %zmm0, %zmm0
 -      -      -      -     0.02   0.33   1.00    -      -     0.65   vmovdqu64 %zmm0, (%rax)
 -      -      -     0.98    -      -      -      -     0.02    -     addq      $48, %rdx
 -      -     0.98    -      -      -      -     0.02    -      -     addq      $64, %rax
 -      -      -     0.02    -      -      -      -     0.98    -     cmpl      $47, %r8d
 -      -     0.04    -      -      -      -      -     0.96    -     jg        .L3


Timeline view:
                    0123456789
Index     0123456789          012

[0,0]     DeeeeeeeeeeER  .    .    vpermb (%rdx), %zmm2, %zmm0
[0,1]     DeE---------R  .    .    subl   $48, %r8d
[0,2]     D==========eER .    .    vpmultishiftqb %zmm0, %zmm3, %zmm0
[0,3]     D===========eeeER   .    vpermb %zmm1, %zmm0, %zmm0
[0,4]     .D=============eER  .    vmovdqu64      %zmm0, (%rax)
[0,5]     .DeE-------------R  .    addq   $48, %rdx
[0,6]     .DeE-------------R  .    addq   $64, %rax
[0,7]     .DeE-------------R  .    cmpl   $47, %r8d
[0,8]     .D=eE------------R  .    jg     .L3

[1,0]     . DeeeeeeeeeeE---R  .    vpermb (%rdx), %zmm2, %zmm0
[1,1]     . DeE------------R  .    subl   $48, %r8d
[1,2]     . D==========eE--R  .    vpmultishiftqb %zmm0, %zmm3, %zmm0
[1,3]     . D===========eeeER .    vpermb %zmm1, %zmm0, %zmm0
[1,4]     .  D=============eER.    vmovdqu64      %zmm0, (%rax)
[1,5]     .  DeE-------------R.    addq   $48, %rdx
[1,6]     .  DeE-------------R.    addq   $64, %rax
[1,7]     .  DeE-------------R.    cmpl   $47, %r8d
[1,8]     .  D=eE------------R.    jg     .L3

[2,0]     .   DeeeeeeeeeeE---R.    vpermb (%rdx), %zmm2, %zmm0
[2,1]     .   DeE------------R.    subl   $48, %r8d
[2,2]     .   D==========eE--R.    vpmultishiftqb %zmm0, %zmm3, %zmm0
[2,3]     .   D===========eeeER    vpermb %zmm1, %zmm0, %zmm0
[2,4]     .    D=============eER   vmovdqu64      %zmm0, (%rax)
[2,5]     .    DeE-------------R   addq   $48, %rdx
[2,6]     .    DeE-------------R   addq   $64, %rax
[2,7]     .    DeE-------------R   cmpl   $47, %r8d
[2,8]     .    D=eE------------R   jg     .L3
Результат получен на анализаторе загрузки ядра, LLVM-MCA для целевой платформы Ice Lake. Видно, что на каждый цикл алгоритма, см. строчки с [1.0] по [1.8] выходит результат, тратится всего два такта. Долго запрягаем, загружаем конвейер команд в работу, потом быстро-быстро грузим. Пропускная способность алгоритма определяется не задержкой, а тем как быстро проходится второй, третий, и последующие циклы. Первая таблица показывает загрузку по портам(вычислительным блокам). Вычислительные блоки не однородные, первые два - это делители, есть четыре унифицированных АЛУ, два канала загрузки из кеш и один канал сохранения в кеш. Векторные инструкции неравномерно распределяются между вычислительными блоками. Некоторые инструкции AVX могут работать параллельно. Если в спецификации на процессор написано два модуля AVX-512 это значит что инструкции AVX-512 могут выходить две за такт, в параллель по разным портам. Одна инструкция может быть представлена последовательностью микроинструкций, которые грузятся сразу на несколько вычислительных блоков. D- дешифрация команд, e - обработка на конвейере, E - исполнение, R - переназначение/сохранение регистров, = - простой планировщика, - простой порта.
В отсутствие возможности пощупать конкретную архитектуру или будущую систему команд, Intel предлагает отлаживать алгоритмы на эмуляторе Intel Software Development Emulator [SDE]. А оптимизацию предлагает тестировать на этом самом Анализаторе LLVM-MCA. Так и сделал. Алгоритм BASE64 отлаживал на эмуляторе SDE.

$ gcc -march=icelake-client -DTEST_BASE64 -O3 -o base64.exe base64.c
$ echo -n 'hello world!' | sde.exe -icx -- ./base64.exe