Как суммировать четыре двухбитовых битовых поля в одном 8-битном байте?

У меня есть четыре двухбитовых битовых поля, хранящихся в одном байте. Каждое битовое поле может, таким образом, представлять 0, 1, 2 или 3. Например, здесь 4 возможных значения, где первые 3 битовых поля равны нулю:

00 00 00 00 = 0 0 0 0
00 00 00 01 = 0 0 0 1
00 00 00 10 = 0 0 0 2
00 00 00 11 = 0 0 0 3

Я хотел бы эффективный способ суммировать четыре битовых поля. Например:

11 10 01 00 = 3 + 2 + 1 + 0 = 6

8-битная таблица поиска на современном процессоре Intel x64 занимает 4 цикла, чтобы вернуть ответ от L1. Кажется, должен быть какой-то способ быстрее вычислить ответ. 3 цикла дают место для 6-12 простых бит операций. В качестве стартера прямая маска и сдвиг выглядят так, как будто это займет 5 циклов на Sandy Bridge:

Предполагая, что битовые поля: d c b a, и эта маска: 00 00 00 11

Разъяснение с помощью Иры: это предполагает, что a, b, c и d идентичны и все были установлены на начальную byte. Как ни странно, я могу сделать это бесплатно. Поскольку я могу сделать 2 загрузки за цикл, вместо загрузки byte один раз, я могу просто загрузить его четыре раза: a и d в первом цикле, b и c на втором. Вторые две нагрузки будут отложены на один цикл, но я не нуждаюсь в них до второго цикла. Разделение ниже показывает, как вещи должны разрываться на отдельные циклы.

a = *byte
d = *byte

b = *byte
c = *byte

latency

latency

a &= mask
d >>= 6

b >>= 2
c >>= 4
a += d

b &= mask
c &= mask

b += c

a += b

Другая кодировка для битподов, чтобы облегчить логику, на самом деле была бы прекрасной, если она вписывается в один байт и каким-то образом сопоставляет ее с этой схемой. Отбрасывание на сборку тоже прекрасное. Текущая цель - Sandy Bridge, но цель Haswell или за ее пределами тоже прекрасна.

Применение и мотивация: я пытаюсь сделать процедуру распаковки битов с открытым исходным кодом быстрее. Каждое битовое поле представляет собой сжатую длину каждого из следующих четырех целых чисел. Мне нужна сумма, чтобы узнать, сколько байтов мне нужно перепрыгнуть, чтобы перейти к следующей группе из четырех. Текущая петля занимает 10 циклов, причем 5 из них подходят для поиска, который я пытаюсь избежать. Бритье цикла будет ~ 10% улучшения.

Изменить: Первоначально я сказал "8 циклов", но, как указывает Евгений, я ошибался. Как указывает Евгений, единственный раз, когда есть косвенная 4-х циклическая нагрузка, - это загрузка из первых 2K системной памяти без использования индексного регистра. Правильный список задержек можно найти в Руководство по оптимизации архитектуры Intel Раздел 2.12

>    Data Type       (Base + Offset) > 2048   (Base + Offset) < 2048 
>                     Base + Index [+ Offset]
>     Integer                5 cycles               4 cycles
>     MMX, SSE, 128-bit AVX  6 cycles               5 cycles
>     X87                    7 cycles               6 cycles 
>     256-bit AVX            7 cycles               7 cycles

Редактирование: Я думаю, именно так решение Айры ниже будет разбиваться на циклы. Я думаю, что он также занимает 5 циклов рабочей нагрузки.

a = *byte
b = *byte

latency

latency 

latency

a &= 0x33
b >>= 2

b &= 0x33
c = a

a += b
c += b

a &= 7
c >>= 4

a += c 

Ответ 1

Другие ответы предлагают различные средства для объединения значений, сидящих в одной переменной (без их распаковки). Хотя эти подходы дают довольно хорошую пропускную способность (в частности, POPCNT), они имеют большую задержку - либо из-за длинных вычислительных цепей, либо из-за использования команд с высокой задержкой.

Лучше использовать обычные инструкции добавления (одновременно складывая одну пару значений), используйте простые операции, такие как маски и сдвиги, чтобы разделить эти значения друг от друга и использовать уровень parallelism на уровне инструкций, чтобы сделать это эффективно, Также позиция двух средних значений в байтовых подсказках для варианта поиска таблицы, который использует один 64-разрядный регистр вместо памяти. Все это позволяет ускорить вычисление суммы четырех и использовать только 4 или 5 часов.

Исходный подход к поиску таблицы, предложенный в OP, может состоять из следующих шагов:

  • байт нагрузки с четырьмя значениями из памяти (5 часов)
  • вычислить сумму значений с помощью таблицы поиска (5 часов)
  • указатель обновления (1 такт)

64-байтовый поиск регистра

Следующий фрагмент показывает, как выполнить шаг № 2 в 5 часах, а также объединить шаги № 2 и № 3, сохраняя задержка еще на 5 тактов (что может быть оптимизировано для 4 тактов со сложным режимом адресации для загрузки памяти):

p += 5 + (*p & 3) + (*p >> 6) +
  ((0x6543543243213210ull >> (*p & 0x3C)) & 0xF);

Здесь константа "5" означает, что мы пропускаем текущий байт с длиной, а также 4 байта данных, соответствующие всем нулевым длинам. Этот фрагмент соответствует следующему коду (только для 64-разрядных):

mov eax, 3Ch
and eax, ebx              ;clock 1
mov ecx, 3
and ecx, ebx              ;clock 1
shr ebx, 6                ;clock 1
add ebx, ecx              ;clock 2
mov rcx, 6543543243213210h
shr rcx, eax              ;clock 2..3
and ecx, Fh               ;clock 4
add rsi, 5
add rsi, rbx              ;clock 3 or 4
movzx ebx, [rsi + rcx]    ;clock 5..9
add rsi, rcx

Я попытался создать этот код автоматически со следующими компиляторами: gcc 4.6.3, clang 3.0, icc 12.1.0. Первые два из них ничего хорошего не делали. Но компилятор Intel выполнил работу почти идеально.


Быстрое извлечение битового поля с инструкцией ROR

Изменить: Тесты Nathan показывают проблему с последующим подходом. Команда ROR на Sandy Bridge использует два порта и конфликты с инструкцией SHR. Таким образом, для этого кода требуется еще 1 такт на Sandy Bridge, что делает его не очень полезным. Вероятно, это сработало бы так, как ожидалось, на Айви-Бридже и Хасуэлле.

Нет необходимости использовать трюк с 64-разрядным регистром в качестве справочной таблицы. Вместо этого вы можете просто повернуть байт на 4 бита, который помещает два средних значения в позиции первого и четвертого значений. Тогда вы можете обрабатывать их одинаково. Такой подход имеет как минимум один недостаток. Не так-то просто выразить поворот байт в C. Также я не совсем уверен в этом ротации, потому что на более старых процессорах это может привести к частичному регистровому столу. Руководства по оптимизации руководства, что для Sandy Bridge мы могли обновить часть регистра, если источник операций совпадает с местом назначения, без стойла. Но я не уверен, что правильно понял. И у меня нет подходящего оборудования, чтобы проверить это. Во всяком случае, вот код (теперь он может быть либо 32-битным, либо 64-битным):

mov ecx, 3
and ecx, ebx              ;clock 1
shr ebx, 6                ;clock 1
add ebx, ecx              ;clock 2
ror al, 4                 ;clock 1
mov ecx, 3
and ecx, eax              ;clock 2
shr eax, 6                ;clock 2
add eax, ecx              ;clock 3
add esi, 5
add esi, ebx              ;clock 3
movzx ebx, [esi+eax]      ;clocks 4 .. 8
movzx eax, [esi+eax]      ;clocks 4 .. 8
add esi, eax

Использование границы между AL и AH для распаковки битовых полей

Этот метод отличается от предыдущего только тем, как извлекаются два средних битовых поля. Вместо ROR, который стоит дорого на Sandy Bridge, используется простой сдвиг. Этот сдвиг позиционирует второе битовое поле в регистре AL и третьем битовом поле - в AH. Затем они извлекаются с помощью сдвигов/масок. Как и в предыдущем методе, здесь есть возможности для частичного регистрации, теперь в двух командах вместо одного. Но это очень вероятно, что Sandy Bridge и более новые процессоры могут выполнить их без задержки.

mov ecx, 3
and ecx, ebx              ;clock 1
shr ebx, 6                ;clock 1
add ebx, ecx              ;clock 2
shl eax, 4                ;clock 1
mov edx, 3
and dl, ah                ;clock 2
shr al, 6                 ;clock 2
add dl, al                ;clock 3
add esi, 5
add esi, ebx              ;clock 3
movzx ebx, [esi+edx]      ;clock 4..8
movzx eax, [esi+edx]      ;clock 4..8
add esi, edx

Загрузка и вычисление суммы параллельно

Также не нужно загружать байты длиной 4 длины и вычислять сумму последовательно. Вы можете выполнять все эти операции параллельно. Есть только 13 значений для суммы четырех. Если ваши данные сжимаемы, вы вряд ли увидите, что эта сумма больше 7. Это означает, что вместо загрузки одного байта вы можете загрузить первые 8 наиболее вероятных байтов в 64-разрядный регистр. И вы могли бы сделать это раньше, чем вычислить сумму четырех. 8 значений загружаются при вычислении суммы. Тогда вы просто получите правильное значение из этого регистра со сдвигом и маской. Эта идея может использоваться вместе с любыми средствами для вычисления суммы. Здесь он используется с простым поиском таблицы:

typedef unsigned long long ull;
ull four_lengths = *p;
for (...)
{
  ull preload = *((ull*)(p + 5));
  unsigned sum = table[four_lengths];
  p += 5 + sum;

  if (sum > 7)
    four_lengths = *p;
  else
    four_lengths = (preload >> (sum*8)) & 15;
}

При правильном коде сборки это добавляет только 2 такта к задержке: shift и mask. Который дает 7 тактов (но только на сжимаемых данных).

Если вы измените поиск таблицы на вычисления, вы можете получить задержку цикла всего 6 тактов: 4 для добавления значений и обновления указателя, а для смены и маски - 2. Интересно, что в этом случае латентность цикла определяется только вычислениями и не зависит от задержки для загрузки памяти.


Загрузка и вычисление суммы параллельно (детерминированный подход)

Выполнение нагрузки и суммирование параллельно могут быть сделаны детерминированным способом. Загрузка двух 64-битных регистров, а затем выбор одной из них с CMP + CMOV - одна из возможностей, но она не улучшает производительность при последовательном вычислении. Другая возможность - использовать 128-битные регистры и AVX. Миграция данных между 128-битными регистрами и GPR/памятью добавляет значительную задержку (но половина этой задержки может быть удалена, если мы обрабатываем два блока данных на итерацию). Также нам нужно будет использовать выровненные по байтам нагрузки памяти в регистры AVX (что также добавляет задержку цикла).

Идея состоит в том, чтобы выполнять все вычисления в AVX, за исключением загрузки памяти, которая должна выполняться из GPR. (Существует альтернатива делать все в AVX и использовать трансляцию + добавлять + собирать на Haswell, но вряд ли она будет быстрее). Также должно быть полезно чередовать загрузку данных в пару регистров AVX (для обработки двух блоков данных на итерацию). Это позволяет парам операций загрузки частично перекрываться и отменяет половину дополнительной задержки.

Начните с распаковки собственного байта из регистра:

vpshufb xmm0, xmm6, xmm0      ; clock 1

Добавьте вместе четыре битовых поля:

vpand xmm1, xmm0, [mask_12]   ; clock 2 -- bitfields 1,2 ready
vpand xmm2, xmm0, [mask_34]   ; clock 2 -- bitfields 3,4 (shifted)
vpsrlq xmm2, xmm2, 4          ; clock 3 -- bitfields 3,4 ready
vpshufb xmm1, xmm5, xmm1      ; clock 3 -- sum of bitfields 1 and 2
vpshufb xmm2, xmm5, xmm2      ; clock 4 -- sum of bitfields 3 and 4
vpaddb xmm0, xmm1, xmm2       ; clock 5 -- sum of all bitfields

Затем обновите адрес и загрузите следующий вектор байтов:

vpaddd xmm4, xmm4, [min_size]
vpaddd xmm4, xmm4, xmm1       ; clock 4 -- address + 5 + bitfields 1,2
vmovd esi, xmm4               ; clock 5..6
vmovd edx, xmm2               ; clock 5..6
vmovdqu xmm6, [esi + edx]     ; clock 7..12

Затем повторите один и тот же код еще раз, используя xmm7 вместо xmm6. Пока загружается xmm6, мы можем обрабатывать xmm7.

В этом коде используются несколько констант:

min_size = 5, 0, 0, ...
mask_12 = 0x0F, 0, 0, ...
mask_34 = 0xF0, 0, 0, ...
xmm5 = lookup table to add together two 2-bit values

Цикл, реализованный, как описано здесь, требует 12 тактов для завершения и "перескакивает" два блока данных одновременно. Что означает 6 циклов на блок данных. Это число может быть слишком оптимистичным. Я не уверен, что для MOVD требуется всего 2 такта. Также неясно, что такое латентность команды MOVDQU, выполняющая неизменную загрузку памяти. Я подозреваю, что MOVDQU имеет очень высокую задержку, когда данные пересекают границу линии кэша. Я полагаю, что это означает что-то вроде 1 дополнительных часов латентности в среднем. Таким образом, примерно 7 циклов на блок данных являются более реалистичными.


Использование грубой силы

Подсказка только одного или двух блоков данных на итерацию удобна, но не полностью использует ресурсы современных процессоров. После некоторой предварительной обработки мы можем реализовать переход прямо к первому блоку данных в следующих выровненных 16 байтах данных. Предварительная обработка должна считывать данные, вычислять сумму четырех полей для каждого байта, использовать эту сумму для вычисления "ссылок" на следующие четыре байтовые поля и, наконец, следовать этим "ссылкам" до следующего выровненного 16-байтового блока, Все эти вычисления независимы и могут быть вычислены в любом порядке с использованием набора инструкций SSE/AVX. AVX2 будет выполнять предварительную обработку в два раза быстрее.

  • Загрузите 16 или 32-байтовый блок данных с MOVDQA.
  • Добавьте вместе 4 битовых поля каждого байта. Чтобы сделать это, извлеките высокие и низкие 4-битные куски с двумя инструкциями PAND, смените высокий кусок на PSRL *, найдите сумму каждого полубайта с двумя PSHUFB и добавьте две суммы с PADDB. (6 часов)
  • Используйте PADDB для вычисления "ссылок" на следующие четыре поля: добавьте константы 0x75, 0x76,... в байты регистра XMM/YMM. (1 uop)
  • Следуйте "ссылкам" с PSHUFB и PMAXUB (более дорогая альтернатива PMAXUB - это комбинация PCMPGTB и PBLENDVB). VPSHUFB ymm1, ymm2, ymm2 выполняет почти всю работу. Он заменяет значения "вне диапазона" нулем. Затем VPMAXUB ymm2, ymm1, ymm2 восстанавливает исходные "ссылки" вместо этих нулей. Достаточно двух итераций. После каждого итерационного расстояния для каждой "ссылки" в два раза больше, поэтому нам нужны только log (longest_chain_length) итерации. Например, самая длинная цепь 0- > 5- > 10- > 15- > X будет сжиматься до 0- > 10- > X после одного шага и до 0- > X после двух шагов. (4 раза)
  • Вычитайте 16 из каждого байта с помощью PSUBB и (только для AVX2) извлеките высокие 128 бит в отдельный регистр XMM с помощью VEXTRACTI128. (2 раза)
  • Теперь предварительная обработка завершена. Мы можем следовать "ссылкам" на первый блок данных в следующем 16-байтовом фрагменте данных. Это можно сделать с помощью PCMPGTB, PSHUFB, PSUBB и PBLENDVB. Но если мы назначим диапазон 0x70 .. 0x80 для возможных значений "ссылок" , один PSHUFB будет корректно работать (на самом деле это пара PSHUFB, в случае AVX2). Значения 0x70 .. 0x7F выберите правильный байт из следующего 16-байтового регистра, а значение 0x80 пропустит следующие 16 байтов и загрузит байт 0, что именно то, что необходимо. (2 часа, латентность = 2 такта)

Инструкции для этих 6 шагов не нужно заказывать последовательно. Например, инструкции для шагов 5 и 2 могут стоять рядом друг с другом. Инструкции для каждого шага должны обрабатывать 16/32-байтовые блоки для разных этапов конвейера, например: шаг 1 обрабатывает блок i, этап 2 обрабатывает блок i-1, шаги 3,4 процесса i-2 и т.д.

Задержка всего цикла может составлять 2 такта (на 32 байта данных). Но ограничивающим фактором здесь является пропускная способность, а не латентность. Когда используется AVX2, нам нужно выполнить 15 часов, что означает 5 часов. Если данные не сжимаются, а блоки данных большие, это дает около 3 тактов на блок данных. Если данные сжимаемы, а блоки данных малы, это дает около 1 такта на блок данных. (Но поскольку время ожидания MOVDQA равно 6 часам, чтобы получить 5 тактов на 32 байта, нам нужны две перекрывающиеся нагрузки и обрабатывать в два раза больше данных в каждом цикле).

Шаги предварительной обработки не зависят от шага # 6. Таким образом, они могут выполняться в разных потоках. Это может сократить время на 32 байта данных ниже 5 тактов.

Ответ 2

Помогла ли встроенная инструкция POPCOUNT?

n = POPCOUNT(byte&0x55);
n+= 2*POPCOUNT(byte&0xAA)

Или, может быть,

  word = byte + ((byte&0xAA) << 8);
  n = POPCOUNT(word);

Не уверен относительно общего времени. В этом обсуждении говорится, что popcount имеет 3 цикла задержки, 1 пропускную способность.


UPDATE:
Возможно, мне не хватает какого-то важного факта о том, как запустить IACA, но после нескольких экспериментов в диапазоне пропускной способности 12-11 я скомпилировал следующее:

 uint32_t decodeFast(uint8_t *in, size_t count) {
  uint64_t key1 = *in;
  uint64_t key2;
  size_t adv;
  while (count--){
     IACA_START;
     key2=key1&0xAA;
     in+= __builtin_popcount(key1);
     adv= __builtin_popcount(key2);
     in+=adv+4;
     key1=*in;
  }
  IACA_END;
  return key1;
}

с gcc -std=c99 -msse4 -m64 -O3 test.c

и получили 3.55 циклы!?!:

Block Throughput: 3.55 Cycles       Throughput Bottleneck: InterIteration
|  Uops  |  0  - DV  |  1  |  2  -  D  |  3  -  D  |  4  |  5  |    |
---------------------------------------------------------------------
|   1    |           | 1.0 |           |           |     |     |    | popcnt edx,eax
|   1    | 0.9       |     |           |           |     | 0.1 | CP | and eax,0x55 
|   1    |           | 1.0 |           |           |     |     | CP | popcnt eax,eax
|   1    | 0.8       |     |           |           |     | 0.2 |    | movsxd rdx,edx
|   1    | 0.6       |     |           |           |     | 0.4 |    | add rdi, rdx
|   1    | 0.1       | 0.1 |           |           |     | 0.9 | CP | cdqe 
|   1    | 0.2       | 0.3 |           |           |     | 0.6 |    | sub rsi, 1
|   1    | 0.2       | 0.8 |           |           |     |     | CP | lea rdi,[rdi+rax+4] 
|   1    |           |     | 0.5   0.5 | 0.5   0.5 |     |     | CP | movzx eax,[rdi]
|   1    |           |     |           |           |     | 1.0 |    | jnz 0xffff

Еще две идеи

Возможная Микро-оптимизация для выполнения суммы в 2 инструкциях

total=0;
PDEP(vals,0x03030303,*in);  #expands the niblets into bytes
PSADBW(total,vals) #total:= sum of abs(0-byte) for each byte in vals

Задержка каждого из них предположительно равна 3, поэтому это может не помочь. Возможно, байтовое суммирование может быть заменено простым сдвигом и добавляется вдоль линий AX=total+total>>16; ADD AL,AH

Макро-оптимизация:
Вы упоминаете использование ключа в качестве поиска в таблице команд перетасовки. Почему бы просто не сохранить расстояние до следующего ключа вместе с инструкцией в случайном порядке? Либо храните большую таблицу, либо, возможно, сжимайте 4-битную длину в неиспользуемые биты 3-6 ключа тасования, за счет необходимости маскировать ее.

Ответ 3

Рассмотрим

 temp = (byte & 0x33) + ((byte >> 2) & 0x33);
 sum = (temp &7) + (temp>>4);

Должно быть 9 машинных инструкций, многие из которых выполняются параллельно. (Сначала попробуйте ОП 9 инструкций плюс некоторые шаги, не упомянутые).

При проверке это, похоже, слишком много последовательных зависимостей для быть победой.

EDIT: обсуждение бинарных операций, являющихся деструктивными, и LEA, избегая этого, заставил меня задуматься о том, как использовать LEA для объединения более одного операнда, и умножение на константы. Вышеприведенный код пытается нормализовать ответ, сдвинувшись вправо, но мы можем закончить нормализацию ответа путем умножения. С этим пониманием этот код может работать:

     mov     ebx,  byte      ; ~1: gotta start somewhere
     mov     ecx, ebx        ; ~2: = byte
     and     ebx, 0xCC       ; ~3: 2 sets of 2 bits, with zeroed holes
     and     ecx, 0x33       ; ~3: complementary pair of bits
     lea     edx, [ebx+4*ecx] ; ~4: sum bit pairs, forming 2 4-bit sums
     lea     edi, [8*edx+edx] ; ~5: need 16*(lower bits of edx)
     lea     edi, [8*edx+edi] ; ~6: edi = upper nibble + 16* lower nibble
     shr     edi, 4           ; ~7: right normalized
     and     edi, 0x0F        ; ~8: masked

Хорошо, развлекательно, но все равно не вышло. 3 часа не очень длинны: - {

Ответ 4

Я не знаю, сколько циклов он может принять, и я мог бы быть полностью выключен, но можно суммировать с помощью 5 простых операций с использованием 32-битных умножений:

unsigned int sum = ((((byte * 0x10101) & 0xC30C3) * 0x41041) >> 18) & 0xF;

Первое умножение повторяет шаблон бит

abcdefgh -> abcdefghabcdefghabcdefgh

Первый бит и сохраняет пару каждые 6 бит:

abcdefghabcdefghabcdefgh -> 0000ef0000cd0000ab0000gh

Второе умножение суммирует шаблон битов (интересует только yyyy)

                     0000ef0000cd0000ab0000gh
             + 0000ef0000cd0000ab0000gh000000
       + 0000ef0000cd0000ab0000gh000000000000
 + 0000ef0000cd0000ab0000gh000000000000000000
 --------------------------------------------
   ..................00yyyy00................

Последние 2 ops сдвигают yyyy вправо и вырезают левую часть

Основная проблема заключается в том, что операторы являются последовательными, хотя...

ИЗМЕНИТЬ

Или просто переведите все это на 10 бит влево и удалите последний бит и:

unsigned int sum = (((byte * 0x4040400) & 0x30C30C00) * 0x41041) >> 28;

Ответ 5

Здесь много замечательных идей, но трудно найти их среди обсуждения. Позвольте использовать этот ответ, чтобы предложить окончательные решения вместе со своим временем. Не стесняйтесь редактировать этот пост и добавлять свои собственные вместе со временем. Если вы не уверены в временной привязке в коде внизу, и я буду ее измерять. x64 будет лучше. Я с удовольствием скомпилирую C, но редко получаю хорошие результаты на этом уровне оптимизации без особых настроек.

Обзор

Перефразируя вопрос, чтобы привести его в правильный контекст: цель состоит в том, чтобы быстро декодировать целочисленный формат сжатия, известный в "Varint-GB" (или Group Varint). Среди других мест он описывается в документе Даниэля Лемура и Лео Бойцова.. Я высказал замечания по первой версии этой статьи стандарта "явно автор - идиотский" стиль, и Даниэль (главный автор статьи, и не столько идиот) хитро запустил меня, чтобы помочь составить код для последующей,

Стандартная Varint (aka VByte) имеет флаг в начале каждого байта, определяющий, является ли это целым целым, но это медленнее для синтаксического анализа. Эта версия имеет один байт "ключ", а затем 4 сжатых целых числа полезной нагрузки. Ключ состоит из 4 двухбитовых полей, каждый из которых представляет длину байта сжатых целых чисел. Каждый из них может быть 1 байт (00), 2 байта (01), 3 байта (10) или 4 байта (11). Каждый "кусок" имеет длину от 5 до 17 байт, но всегда кодирует одинаковое число (4) из 32-разрядных целых чисел без знака.

Sample Chunk:
  Key:  01 01 10 00  
  Data: [A: two bytes] [B: two bytes] [C: three bytes] [D: one byte]
Decodes to: 00 00 AA AA   00 00 BB BB   00 CC CC CC  00 00 00 DD

Ключ - это индекс в таблицу 16-байтных шаблонов тасования, и фактическое декодирование выполняется путем перетасовки байтов данных в правильное расстояние с помощью PSHUFB.

vec_t Data = *input
vec_t ShuffleKey = decodeTable[key]     
VEC_SHUFFLE(Data, ShuffleKey) // PSHUFB
*out = Data

В действительности, как правило, обычно также используется шаг дельта-декодирования, поскольку исходные целые числа обычно уменьшались путем сжатия "дельта" (разности) между целыми числами, а не самих целых чисел. Но латентность для процедуры декодирования обычно не имеет значения, так как следующая итерация не зависит от нее.

Пересмотренная проблема

Задача, указанная здесь, состоит в том, чтобы перейти от одного "ключа" к следующему. Поскольку здесь нет зависимостей от декодированных данных (только на клавише), я проигнорирую фактическое декодирование и просто сконцентрируюсь на цикле, который читает ключи. Функция принимает указатель на ключ и счет n и возвращает n-й ключ.

11 циклов

"Основной" подход заключается в использовании справочной таблицы смещений "вперед" с ключом в качестве индекса. Просмотрите любой из 256 ключей в методе offsetTable, чтобы получить заранее рассчитанное смещение (сумма + 1) для продвижения. Добавьте это в текущую позицию ввода и прочитайте следующий ключ. Согласно Intel IACA, этот цикл занимает 11 циклов на Sandy Bridge (цикл также считается на Sandy Bridge).

uint32_t decodeBasic(uint8_t *in, size_t count) {
    uint64_t key, advance;
    for (size_t i = count; i > 0; i--) {
        key = *in;
        advance = offsetTable[key];
        in += advance;
    }
    return key;
}

0000000000000000 <decodeBasic>:
   0:   test   %rsi,%rsi
   3:   je     19 <decodeBasic+0x19>
   5:   nopl   (%rax)
   8:   movzbl (%rdi),%eax
   b:   add    0x0(,%rax,8),%rdi
  13:   sub    $0x1,%rsi
  17:   jne    8 <decodeBasic+0x8>
  19:   repz retq 

Block Throughput: 11.00 Cycles       Throughput Bottleneck: InterIteration
   0  - DV  |  1  |  2  -  D  |  3  -  D  |  4  |  5  |    |
--------------------------------------------------------------
|           |     | 1.0   1.0 |           |     |     | CP | movzx eax, byte ptr [rdi]
| 0.3       | 0.3 |           | 1.0   1.0 |     | 0.3 | CP | add rdi, qword ptr [rax*8]
|           |     |           |           |     | 1.0 |    | sub rsi, 0x1
|           |     |           |           |     |     |    | jnz 0xffffffffffffffe7

10 циклов

Оттуда мы можем перейти к 10 циклам, переставив петлю, чтобы добавить обновление указателя ввода и начать загрузку следующего ключа одновременно. Вы можете заметить, что мне пришлось использовать встроенную сборку, чтобы "поощрять" компилятор к выпуску, который я хотел. Я также начну отбрасывать внешний цикл, поскольку он (обычно) остается прежним.

key = *in;
advance = offsetTable[key]; 
for (size_t i = count; i > 0; i--) {
    key = *(in + advance);
    ASM_LEA_ADD_BASE(in, advance);
    advance = offsetTable[key];
}

Block Throughput: 10.00 Cycles       Throughput Bottleneck: InterIteration
|  0  - DV  |  1  |  2  -  D  |  3  -  D  |  4  |  5  |    |
------------------------------------------------------------
|           |     | 1.0   1.0 |           |     |     | CP | movzx eax, byte ptr [rdi+rdx*1]
| 0.5       | 0.5 |           |           |     |     |    | lea rdi, ptr [rdi+rdx*1]
|           |     |           | 1.0   1.0 |     |     | CP | mov rdx, qword ptr [rax*8]
|           |     |           |           |     | 1.0 |    | sub rsi, 0x1
|           |     |           |           |     |     |    | jnz 0xffffffffffffffe2

9 циклов

Раньше я пытался использовать POPCNT, но без предложений, намеков и идей от Ira и AShelly мне не повезло. Но, собрав кусочки, я думаю, что у меня есть что-то, что запускает цикл за 9 циклов. Я поместил его в фактический декодер, и количество Ints/s, похоже, согласуется с этим. Этот цикл по существу в сборке, так как я не мог заставить компилятор делать то, что я хотел в противном случае, без лишних множественных компиляторов.

[Edit: удалено дополнительное MOV за комментарий от AShelly]

uint64_t key1 = *in;
uint64_t key2 = *in;
for (size_t i = count; i > 0; i--) {
    uint64_t advance1, advance2;
    ASM_POPCOUNT(advance1, key1);
    ASM_AND(key2, 0xAA);

    ASM_POPCOUNT(advance2, key2);
    in += advance1;

    ASM_MOVE_BYTE(key1, *(in + advance2 + 4));
    ASM_LOAD_BASE_OFFSET_INDEX_MUL(key2, in, 4, advance2, 1);        
    in += advance2;
 }


Block Throughput: 9.00 Cycles       Throughput Bottleneck: InterIteration
|  0  - DV  |  1  |  2  -  D  |  3  -  D  |  4  |  5  |    |
------------------------------------------------------------
|           | 1.0 |           |           |     |     | CP | popcnt r8, rax
| 1.0       |     |           |           |     |     | CP | and rdx, 0xaa
|           |     |           |           |     | 1.0 | CP | add r8, rdi
|           | 1.0 |           |           |     |     | CP | popcnt rcx, rdx
|           |     | 1.0   1.0 |           |     |     | CP | movzx rax, byte ptr [rcx+r8*1+0x4]
|           |     |           | 1.0   1.0 |     |     | CP | mov rdx, qword ptr [r8+rcx*1+0x4]
| 1.0       |     |           |           |     |     |    | lea rdi, ptr [rcx+r8*1]
|           |     |           |           |     | 1.0 |    | dec rsi
|           |     |           |           |     |     |    | jnz 0xffffffffffffffd0

Как указание на сложность движущихся частей в современных процессорах, у меня был интересный опыт с вариацией этой подпрограммы. Если я объединю вторую строку mov rax с and rax, 0xaa, указав ячейку памяти с и (mov rax, 0xAA; and rax, qword ptr [r8+rcx*1+0x4]), я закончил процедуру, которая колебалась на 30% для запуска. Я думаю, это потому, что иногда начальные условия, приводящие к циклу, вызывают "и" микрооперацию загрузки/и запускаются до POPCNT для всего цикла.

8 циклов

Кто-нибудь?

Евгений

Это моя попытка реализовать решение Evgeny. Я еще не смог довести его до 9 циклов, по крайней мере, для модели IACA Sandy Bridge (которая до сих пор была точной). Я думаю, проблема в том, что, хотя ROR имеет латентность 1, на P1 или P5 требуется два микрооператора. Чтобы получить задержку в 1, оба должны быть доступны. Остальные - всего лишь один микрооператор, и поэтому всегда есть латентность 1. AND, ADD и MOV могут выдаваться на P0, P1 или P5, но SHR не может быть на P1. Я могу приблизиться к 10 циклам, добавив несколько дополнительных нежелательных операций, которые предотвращают ADD и AND от смещения SHR или ROR, но я не уверен, как добраться ниже 10.

Block Throughput: 10.55 Cycles       Throughput Bottleneck: InterIteration
|  0  - DV  |  1  |  2  -  D  |  3  -  D  |  4  |  5  |    |
------------------------------------------------------------
|           |     | 1.0   1.0 |           |     |     | CP | movzx eax, byte ptr [esi+0x5]
|           |     |           | 1.0   1.0 |     |     | CP | movzx ebx, byte ptr [esi+0x5]
| 0.2       | 0.6 |           |           |     | 0.3 |    | add esi, 0x5
| 0.3       | 0.3 |           |           |     | 0.3 |    | mov ecx, 0x3
| 0.2       | 0.2 |           |           |     | 0.6 |    | mov edx, 0x3
| 1.4       |     |           |           |     | 0.6 | CP | ror al, 0x4
| 0.1       | 0.7 |           |           |     | 0.2 | CP | and ecx, ebx
| 0.6       |     |           |           |     | 0.4 | CP | shr ebx, 0x6
| 0.1       | 0.7 |           |           |     | 0.2 | CP | add ebx, ecx
| 0.3       | 0.4 |           |           |     | 0.3 | CP | and edx, eax
| 0.6       |     |           |           |     | 0.3 | CP | shr eax, 0x6
| 0.1       | 0.7 |           |           |     | 0.2 | CP | add eax, edx
| 0.3       | 0.3 |           |           |     | 0.3 | CP | add esi, ebx
| 0.2       | 0.2 |           |           |     | 0.6 | CP | add esi, eax

Ответ 6

  mov al,1
  mov ah,2
  mov bl,3
  mov bh,4
  add ax,bx
  add al,ah