Вычитание упакованных 8-битных целых чисел в 64-битное целое на 1 параллельно, SWAR без аппаратного SIMD - PullRequest
77 голосов
/ 08 января 2020

Если у меня 64-разрядное целое число, которое я интерпретирую как массив упакованных 8-разрядных целых чисел с 8 элементами. Мне нужно вычесть постоянную 1 из каждого упакованного целого числа при обработке переполнения без влияния одного элемента на результат другого элемента.

У меня есть этот код в данный момент, и он работает, но мне нужно решение это делает вычитание каждого упакованного 8-битного целого числа параллельно и не делает доступ к памяти. На x86 я мог бы использовать SIMD-инструкции, такие как psubb, которые вычитают упакованные 8-битные целые числа параллельно, но платформа, для которой я кодирую, не поддерживает SIMD-инструкции. (RIS C -V в данном случае).

Поэтому я пытаюсь сделать SWAR (SIMD в регистре) , чтобы вручную отменить распространение переноса между байтами uint64_t, что-то эквивалентное этому:

uint64_t sub(uint64_t arg) {
    uint8_t* packed = (uint8_t*) &arg;

    for (size_t i = 0; i < sizeof(uint64_t); ++i) {
        packed[i] -= 1;
    }

    return arg;
}

Я думаю, вы могли бы сделать это с помощью побитовых операторов, но я не уверен. Я ищу решение, которое не использует инструкции SIMD. Я ищу решение в C или C ++, которое достаточно переносимо, или просто теорию, которая стоит за ним, чтобы я мог реализовать свое собственное решение.

Ответы [ 8 ]

75 голосов
/ 08 января 2020

Если у вас есть ЦП с эффективными инструкциями SIMD, SSE / MMX paddb (_mm_add_epi8) также является жизнеспособным. В ответе Питера Кордеса также описывается векторный синтаксис GNU C (gcc / clang) и безопасность для строго псевдонимов UB. Я настоятельно рекомендую просмотреть и этот ответ.

Самостоятельная работа с uint64_t полностью переносима, но все же требует осторожности, чтобы избежать проблем выравнивания и строгого наложения UB при доступе к массиву uint8_t с * 1009. *. Вы оставили эту часть вне вопроса, начав с ваших данных уже в uint64_t, но для GNU C a may_alias typedef решает проблему (см. Ответ Питера или memcpy).

В противном случае вы можете выделить / объявить ваши данные как uint64_t и получить к ним доступ через uint8_t*, когда вам нужны отдельные байты. unsigned char* разрешено создавать псевдонимы, чтобы избежать проблемы для конкретного c случая 8-битных элементов. (Если uint8_t существует вообще, вероятно, можно с уверенностью предположить, что это unsigned char.)


Обратите внимание, что это изменение от предыдущего неправильного алгоритма (см. Историю изменений).

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

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

SWAR sub z = x - y
    z = ((x | H) - (y &~H)) ^ ((x ^~y) & H)

с H, определенным как 0x8080808080808080U (т.е. MSB каждого упакованного целого числа). Для декремента y равно 0x0101010101010101U.

Мы знаем, что y очищает все свои MSB, поэтому мы можем пропустить один из шагов маски (т. Е. y & ~H совпадает с y в нашем случае). Вычисление происходит следующим образом:

  1. Мы устанавливаем MSB каждого компонента x в 1, чтобы заем не мог распространяться за MSB до следующего компонента. Назовите это скорректированным вводом.
  2. Мы вычитаем 1 из каждого компонента, вычитая 0x01010101010101 из скорректированного ввода. Это не вызывает межкомпонентные заимствования благодаря шагу 1. Назовите это скорректированным выводом.
  3. Теперь нам нужно исправить MSB результата. Мы скорректируем скорректированный вывод с инвертированными старшими значащими битами исходного ввода до конечного значения sh, фиксируя результат.

Операция может быть записана как:

#define U64MASK 0x0101010101010101U
#define MSBON 0x8080808080808080U
uint64_t decEach(uint64_t i){
      return ((i | MSBON) - U64MASK) ^ ((i ^ MSBON) & MSBON);
}

Предпочтительно, это указывается компилятором (используйте директивы компилятора , чтобы вызвать это), или выражение записывается как часть другой функции.

Тестовые случаи:

in:  0000000000000000
out: ffffffffffffffff

in:  f200000015000013
out: f1ffffff14ffff12

in:  0000000000000100
out: ffffffffffff00ff

in:  808080807f7f7f7f
out: 7f7f7f7f7e7e7e7e

in:  0101010101010101
out: 0000000000000000

Сведения о производительности

Вот сборка x86_64 для одного вызова функции. Для лучшей производительности это должно быть выражено надеждой, что константы могут жить в регистре как можно дольше. В узком l oop, где константы живут в регистре, фактическое уменьшение занимает пять инструкций: или + not + и + add + xor после оптимизации. Я не вижу альтернатив, которые могли бы превзойти оптимизацию компилятора.

uint64t[rax] decEach(rcx):
    movabs  rcx, -9187201950435737472
    mov     rdx, rdi
    or      rdx, rcx
    movabs  rax, -72340172838076673
    add     rax, rdx
    and     rdi, rcx
    xor     rdi, rcx
    xor     rax, rdi
    ret

При некотором тестировании IACA следующего фрагмента:

// Repeat the SWAR dec in a loop as a microbenchmark
uint64_t perftest(uint64_t dummyArg){
    uint64_t dummyCounter = 0;
    uint64_t i = 0x74656a6d27080100U; // another dummy value.
    while(i ^ dummyArg) {
        IACA_START
        uint64_t naive = i - U64MASK;
        i = naive + ((i ^ naive ^ U64MASK) & U64MASK);
        dummyCounter++;
    }
    IACA_END
    return dummyCounter;
}


мы можем показать, что на машине Skylake, выполняя декремент, xor и сравнение + переход могут быть выполнены менее чем за 5 циклов за итерацию:

Throughput Analysis Report
--------------------------
Block Throughput: 4.96 Cycles       Throughput Bottleneck: Backend
Loop Count:  26
Port Binding In Cycles Per Iteration:
--------------------------------------------------------------------------------------------------
|  Port  |   0   -  DV   |   1   |   2   -  D    |   3   -  D    |   4   |   5   |   6   |   7   |
--------------------------------------------------------------------------------------------------
| Cycles |  1.5     0.0  |  1.5  |  0.0     0.0  |  0.0     0.0  |  0.0  |  1.5  |  1.5  |  0.0  |
--------------------------------------------------------------------------------------------------

(Конечно, на x86-64 вы просто загрузите или movq в регистр XMM для paddb, поэтому было бы интереснее посмотреть, как он компилируется для ISA, например RIS C -V.)

16 голосов
/ 09 января 2020

Для RIS C -V вы, вероятно, используете GCC / clang.

Интересный факт: G CC знает некоторые из этих хитростей SWAR-трюков (показанных в других ответах) и может использовать их для Вы при компиляции кода с GNU C нативными векторами для целей без аппаратных инструкций SIMD. (Но clang для RIS C -V просто наивно развернет его для скалярных операций, поэтому вам придется делать это самостоятельно, если вы хотите добиться хорошей производительности на всех компиляторах).

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

Это облегчает написание vector -= scalar операций; синтаксис Just Works, неявно вещающий, иначе говоря, скаляр от вас.


Также обратите внимание, что загрузка uint64_t* из uint8_t array[] является строгим псевдонимом UB, поэтому будьте осторожны с этим. (См. Также Почему strlen glibc должен быть настолько сложным, чтобы быстро запускаться? re: сделать бит-хэки SWAR строгим и безопасным в чистом C). Возможно, вы захотите, чтобы что-то вроде этого объявило uint64_t, что вы можете привести указатель для доступа к любым другим объектам, например, как char* работает в ISO C / C ++.

использовать их для получить данные uint8_t в uint64_t для использования с другими ответами:

// GNU C: gcc/clang/ICC but not MSVC
typedef uint64_t  aliasing_u64 __attribute__((may_alias));  // still requires alignment
typedef uint64_t  aliasing_unaligned_u64 __attribute__((may_alias, aligned(1)));

Другой способ сделать безопасные для алиасов нагрузки - это memcpy в uint64_t, который также удаляет alignof(uint64_t) требование выравнивания. Но на ISA без эффективных невыровненных нагрузок gcc / clang не встроен и не оптимизирует memcpy, когда они не могут доказать, что указатель выровнен, что было бы катастрофично для производительности.

TL: DR: лучше всего объявить ваши данные как uint64_t array[...] или динамически распределить их как uint64_t, или предпочтительно alignas(16) uint64_t array[];, что обеспечивает выравнивание по крайней мере до 8 байтов, или 16, если вы укажите alignas.

Поскольку uint8_t почти наверняка unsigned char*, доступ к байтам uint64_t через uint8_t* безопасен (но не наоборот для массива uint8_t). Так что для этого особого случая, когда узкий тип элемента равен unsigned char, вы можете обойти проблему строгого наложения имен, поскольку char является особенным.


GNU C Пример синтаксиса собственного вектора:

GNU C родным векторам всегда разрешено создавать псевдонимы с их базовым типом (например, int __attribute__((vector_size(16))) может безопасно использовать псевдоним int, но не float или uint8_t или что-либо еще.

#include <stdint.h>
#include <stddef.h>

// assumes array is 16-byte aligned
void dec_mem_gnu(uint8_t *array) {
    typedef uint8_t v16u8 __attribute__ ((vector_size (16), may_alias));
    v16u8 *vecs = (v16u8*) array;
    vecs[0] -= 1;
    vecs[1] -= 1;   // can be done in a loop.
}

Для RIS C -V без HW SIMD вы можете использовать vector_size(8) до express только ту гранулярность, которую вы можете эффективно использовать, и использовать в два раза больше меньших векторов.

Но vector_size(8) очень тупо компилируется для x86 как с G CC, так и clang: G CC использует битовые хаки SWAR в целочисленных регистрах GP, clang распаковывает в 2-байтовые элементы для заполнения 16-байтового регистра XMM, а затем перепаковывает. (MMX так устарел что GCC / clang даже не потрудился использовать его, по крайней мере, для x86-64.)

Но с vector_size (16) ( Godbolt ) мы получаем ожидаемое movdqa / paddb. (С вектором из всех единиц, сгенерированным pcmpeqd same,same). С -march=skylake мы все еще получаем две отдельные операции XMM вместо одной YMM, так что, к сожалению, современные компиляторы также не "автоматически векторизуют" векторные операции в более широкие векторы: /

Для AArch64 не так уж плохо использовать vector_size(8) ( Godbolt ); ARM / AArch64 может изначально работать в 8- или 16-байтовых чанках с d или q регистрами.

Таким образом, вы, вероятно, захотите, чтобы vector_size(16) действительно компилировался, если вы хотите переносить производительность на x86 , RIS C -V, ARM / AArch64 и POWER . Однако некоторые другие ISA выполняют SIMD в 64-битных целочисленных регистрах, например, MIPS MSA.

vector_size(8) облегчает просмотр asm (только один регистр данных): Godbolt проводник компилятора

# GCC8.2 -O3 for RISC-V for vector_size(8) and only one vector

dec_mem_gnu(unsigned char*):
        lui     a4,%hi(.LC1)           # generate address for static constants.
        ld      a5,0(a0)                 # a5 = load from function arg
        ld      a3,%lo(.LC1)(a4)       # a3 = 0x7F7F7F7F7F7F7F7F
        lui     a2,%hi(.LC0)
        ld      a2,%lo(.LC0)(a2)       # a2 = 0x8080808080808080
                             # above here can be hoisted out of loops
        not     a4,a5                  # nx = ~x
        and     a5,a5,a3               # x &= 0x7f... clear high bit
        and     a4,a4,a2               # nx = (~x) & 0x80... inverse high bit isolated
        add     a5,a5,a3               # x += 0x7f...   (128-1)
        xor     a5,a4,a5               # x ^= nx  restore high bit or something.

        sd      a5,0(a0)               # store the result
        ret

Я думаю, что это та же самая базовая идея c, что и у других нецикличных ответов; предотвращение переноса, затем исправление результата.

Это 5 инструкций ALU, хуже, чем лучший ответ, я думаю. Но похоже, что задержка критического пути составляет всего 3 цикла, с двумя цепочками по 2 инструкции, каждая из которых ведет к XOR. Ответ @Reinstate Monica - ζ - компилируется в 4-тактную цепочку dep (для x86). Пропускная способность l oop в 5 циклах также является узким местом, поскольку на критическом пути также включается наивное sub, а l oop создает узкие места при задержке.

Однако это бесполезно для clang. Он даже не добавляет и не хранит в том же порядке, в котором загружен, поэтому он даже не выполняет хорошую программную конвейеризацию!

# RISC-V clang (trunk) -O3
dec_mem_gnu(unsigned char*):
        lb      a6, 7(a0)
        lb      a7, 6(a0)
        lb      t0, 5(a0)
...
        addi    t1, a5, -1
        addi    t2, a1, -1
        addi    t3, a2, -1
...
        sb      a2, 7(a0)
        sb      a1, 6(a0)
        sb      a5, 5(a0)
...
        ret
13 голосов
/ 08 января 2020

Я бы отметил, что написанный вами код на самом деле векторизируется, когда вы начинаете работать с более чем одним uint64_t.

https://godbolt.org/z/J9DRzd

11 голосов
/ 08 января 2020

Вы можете убедиться, что вычитание не переполняется, а затем исправить старший бит:

uint64_t sub(uint64_t arg) {
    uint64_t x1 = arg | 0x80808080808080;
    uint64_t x2 = ~arg & 0x80808080808080;
    // or uint64_t x2 = arg ^ x1; to save one instruction if you don't have an andnot instruction
    return (x1 - 0x101010101010101) ^ x2;
}
7 голосов
/ 08 января 2020

Не уверен, что это именно то, что вам нужно, но он выполняет 8 вычитаний параллельно друг другу:

#include <cstdint>

constexpr uint64_t mask = 0x0101010101010101;

uint64_t sub(uint64_t arg) {
    uint64_t mask_cp = mask;
    for(auto i = 0; i < 8 && mask_cp; ++i) {
        uint64_t new_mask = (arg & mask_cp) ^ mask_cp;
        arg = arg ^ mask_cp;
        mask_cp = new_mask << 1;
    }
    return arg;
}

Объяснение: Битовая маска начинается с 1 в каждом из 8-битных чисел. Мы исправим это с помощью нашего аргумента. Если у нас была 1 в этом месте, мы вычли 1 и должны остановиться. Это делается путем установки соответствующего бита в 0 в new_mask. Если у нас был 0, мы устанавливаем его в 1 и должны выполнять перенос, поэтому бит остается равным 1, и мы смещаем маску влево. Вам лучше самим проверить, работает ли генерация новой маски должным образом, я так думаю, но второе мнение не будет плохим.

PS: Я на самом деле не уверен, что проверка на mask_cp не является ноль в l oop может замедлить программу. Без него код по-прежнему был бы правильным (поскольку маска 0 просто ничего не делает), и компилятору было бы намного проще выполнить l oop развертывание.

4 голосов
/ 08 января 2020
int subtractone(int x) 
{
    int f = 1; 

    // Flip all the set bits until we find a 1 at position y
    while (!(x & f)) { 
        x = x^f; 
        f <<= 1; 
    } 

    return x^f; // return answer but remember to flip the 1 at y
} 

Вы можете сделать это с помощью побитовых операций, используя вышеописанное, и вам просто нужно разделить ваше целое число на 8-битные части, чтобы отправить 8 раз в эту функцию. Следующая часть была взята из Как разбить 64-битное число на восемь 8-битных значений? со мной, добавив в вышеупомянутую функцию

uint64_t v= _64bitVariable;
uint8_t i=0,parts[8]={0};
do parts[i++] = subtractone(v&0xFF); while (v>>=8);

Это действительно C или C ++ независимо от того, как кто-то сталкивается с этим

2 голосов
/ 10 января 2020

Не собираюсь пытаться придумать код, но для уменьшения на 1 вы можете уменьшить на группу 8 1, а затем проверить, чтобы убедиться, что младшие биты результатов «перевернулись». Любой LSB, который не переключался, указывает, что перенос произошел из соседних 8 битов. Должна быть возможность разработать последовательность операций AND / ORs / XOR, чтобы справиться с этим, без каких-либо ветвей.

0 голосов
/ 08 января 2020

Сфокусируйте работу на каждом байте в одиночку, затем верните его туда, где он был.

uint64_t sub(uint64_t arg) {
   uint64_t res = 0;

   for (int i = 0; i < 64; i+=8) 
     res += ((arg >> i) - 1 & 0xFFU) << i;

    return res;
   }
...