четверг, 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

Комментариев нет:

Отправить комментарий