Віднімання упакованих 8-бітових цілих чисел у 64-бітовому цілому на 1 паралельно, SWAR без апаратного SIMD


77

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

На даний момент у мене є цей код, і він працює, але мені потрібно рішення, яке робить віднімання кожного упакованого 8-бітного цілого числа паралельно і не робить доступу до пам'яті. На x86 я можу використовувати вказівки SIMD, як psubb-от паралельно віднімає запаковані 8-бітні цілі числа, але платформа, для якої я кодую, не підтримує інструкції SIMD. (RISC-V у цьому випадку).

Тому я намагаюся зробити SWAR (SIMD в регістрі), щоб вручну скасувати виконання перенесення між байтами a 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 ++, яке є досить портативним, або просто теорія, що стоїть за ним, щоб я міг реалізувати власне рішення.


5
Вони повинні бути 8-бітовими чи можуть бути замість них 7-бітовими?
тадман

Їм треба було 8-бітового вибачення :(
cam-white

12
Методи подібного роду називаються SWAR
Гарольд


1
чи очікуєте, що байт містить нуль для завершення до 0xff?
Альнітак

Відповіді:


75

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

Робити це самостійно uint64_t- це повністю портативний, але все-таки вимагає обережності, щоб уникнути проблем з вирівнюванням та суворого псевдоніму UB під час доступу до uint8_tмасиву з a uint64_t*. Ви залишили цю частину питання, починаючи вже зі своїми даними uint64_t, але для GNU C may_aliastypedef вирішує проблему (див. Відповідь Петра на це чи memcpy).

В іншому випадку ви можете виділити / оголосити свої дані як uint64_tта отримати доступ до них, uint8_t*коли вам потрібно окремі байти. unsigned char*Дозволено створювати псевдоніми що завгодно, щоб уникнути проблеми для конкретного випадку 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 результату. Ми регулюємо виправлений вихід за допомогою перевернутого MSB вихідного входу, щоб закінчити фіксацію результату.

Операцію можна записати так:

#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 для одного виклику функції. Для кращої роботи слід зазначити, що константи можуть жити в реєстрі якомога довше. У тісному циклі, де константи живуть у регістрі, фактичний декремент бере п'ять інструкцій: або + не + і + додавання + 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 типу RISC-V.)


4
Мені потрібен мій код для роботи на машинах RISC-V, які не мають інструкцій SIMD (поки що), не кажучи вже про підтримку MMX
cam-white

2
@ cam-white Зрозумів - це, мабуть, найкраще, що ти можеш зробити тоді. Я скачу на Godbolt, щоб розумно перевірити збірку на RISC. Редагувати: Немає підтримки RISC-V на godbolt :(
nanofarad

7
Існує RISC-V підтримка на godbolt насправді, наприклад , як цього (E: здається , що компілятор отримує занадто творчий підхід у створенні маски ..)
Harold

4
Подальше читання про те, як трюк паритету (який також називають "переносним вектором") можна використовувати в різних ситуаціях: emulators.com/docs/LazyOverflowDetect_Final.pdf
jpa

4
Я зробив ще одну редакцію; Рідні вектори GNU C насправді уникають суворих проблем; вектор uint8_tпсевдоніму дозволений для псевдоніму uint8_tданих. Абоненти вашої функції (яким потрібно ввести uint8_tдані в а uint64_t) - це ті, кому доводиться переживати за суворе згладжування! Тож, ймовірно, ОП має просто оголосити / розподілити масиви, uint64_tтому char*що в ISO C ++ дозволено створювати псевдоніми, але не навпаки.
Пітер Кордес

16

Для RISC-V ви, ймовірно, використовуєте GCC / clang.

Факт забави: GCC знає деякі з цих біт-трюків SWAR (показано в інших відповідях) і може використовувати їх для вас під час компіляції коду з націленими векторами GNU C для цілей без апаратних SIMD інструкцій. (Але клакс для RISC-V просто наївно розкрутить його до скалярних операцій, тому вам доведеться робити це самостійно, якщо ви хочете гарної продуктивності у компіляторів).

Однією з переваг нативного синтаксису векторів є те, що при націлюванні на машину з апаратним SIMD він використовуватиме те, що замість автоматичного векторизації вашого бітхака чи чогось жахливого подібного.

Це дозволяє легко писати vector -= scalarоперації; синтаксис «Працює», неявно транслюючи ака, як зарисує скаляр для вас.


Також зауважте, що uint64_t*навантаження з АБ uint8_t array[]є сувороїзмінним UB, тому будьте обережні з цим. (Див. Також, чому струн glibc повинен бути таким складним для швидкого запуску? Повторне: створення SWAR bithacks суворо-псевдонім безпечним у чистому С). Можливо, ви хочете, щоб щось подібне оголосило, 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_tvia 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.
}

Для RISC-V без будь-якої SIM-карти HW ви можете використовувати vector_size(8)для вираження лише деталізацію, яку ви можете ефективно використовувати, і робити вдвічі більше дрібних векторів.

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

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

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

Тож ви, мабуть, хочете vector_size(16) насправді компілювати, якщо ви хочете переносити продуктивність у x86, RISC-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

Я думаю, що це та сама основна ідея, що й інші відповіді; запобігання переносу, то фіксація результату.

Це 5 інструкцій ALU, гірше, ніж головна відповідь, я думаю. Але схоже, що критична затримка шляху становить лише 3 цикли, причому два ланцюги по 2 інструкції ведуть до XOR. @Reinstate Monica - відповідь компілюється у 4-цикльний ланцюг dep (для x86). Пропускна здатність 5-циклового циклу обмежується також включенням наївнихsub на критичному шляху, і цикл робить вузьке місце затримкою.

Однак це з маркуванням марно. Він навіть не додає та зберігає в тому самому порядку, який він завантажив, тому навіть не робить хорошого конвеєрного програмного забезпечення!

# 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

Я зазначу, що написаний вами код насправді векторизується, коли ви починаєте мати справу з більш ніж одним uint64_t.

https://godbolt.org/z/J9DRzd


1
Чи можете ви пояснити чи дати посилання на те, що там відбувається? Це здається досить цікавим.
n314159

2
Я намагався зробити це без інструкцій SIMD, але все-таки мені це було цікаво :)
cam-white

8
З іншого боку, цей SIMD-код жахливий. Укладач повністю не зрозумів, що тут відбувається. Е: це приклад "це чітко зробив компілятор, тому що жодна людина не буде такою дурною"
harold

1
@PeterCordes: Я думав більше за принципами __vector_loop(index, start, past, pad)конструкції, яку реалізація могла б сприймати як for(index=start; index<past; index++)[означає, що будь-яка реалізація може обробляти код, використовуючи її, просто шляхом визначення макросу], але яка мала б більш слабку семантику, щоб запросити компілятора для обробки речей у будь-який розмір шматка потужності до pad, збільшуючи початок вниз і кінець вгору, якщо вони вже не кратні розміру шматка. Побічні ефекти всередині кожного шматка будуть наслідком, а якщо breakтрапляється всередині циклу, інші повторення ...
supercat

1
@PeterCordes: Хоча restrictце корисно (і було б корисніше, якщо Стандарт визнав би поняття "принаймні потенційно засноване на", а потім визначено "на основі" і "принаймні потенційно на основі" прямо без тупих і непрацездатних кутових випадків) моя пропозиція також дозволила б компілятору виконати більше виконання циклу, ніж вимагається - що значно спростить векторизацію, але для якої Стандарт не передбачає.
supercat

11

Ви можете переконатися, що віднімання не переповнюється, а потім виправити високий біт:

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;
}

Я думаю, що це працює для всіх 256 можливих значень байта; Я поклав це на Godbolt (з клаксом RISC-V) godbolt.org/z/DGL9aq, щоб переглянути результати постійного розповсюдження для різних входів, таких як 0x0, 0x7f, 0x80 та 0xff (зміщені в середину числа). Виглядає добре. Я думаю, що відповідь вгорі зводиться до того ж самого, але це пояснюється більш складним чином.
Пітер Кордес

Компілятори могли б краще виконати конструювання констант тут. Кланг витрачає чимало інструкцій на конструювання splat(0x01)і splat(0x80), замість того, щоб отримувати одне з іншого зі зміною. Навіть записавши це таким чином у вихідному документі godbolt.org/z/6y9v-u , не перекладайте компілятор на створення кращого коду; він просто робить постійне розповсюдження.
Пітер Кордес

Цікаво, чому він не просто завантажує константу з пам'яті; ось що роблять компілятори для Альфа (схожа архітектура).
Falk Hüffner

GCC для RISC-V виконує завантаження констант з пам'яті. Схоже, що кланг потребує певної настройки, якщо не очікуються помилки в кеші даних і вони дорогі порівняно з пропускною здатністю інструкцій. (Цей баланс, безумовно, може змінитися з моменту Alpha, і, мабуть, різні реалізації RISC-V відрізняються. Компілятори також могли б зробити набагато краще, якби зрозуміли, що це повторювана схема, яку вони можуть змінити / АБО розширити після початку роботи з одним LUI / add для 20 + 12 = 32 біт негайних даних. Бітові шаблони біт-шаблону AArch64 можуть навіть використовувати їх як прямі для AND / OR / XOR, вибір розумного декодування проти густини)
Peter Cordes

Додано відповідь, що показує натиснуту SWAR на GCC для RISC-V
Пітер Кордес

7

Не впевнений, що це те, що ви хочете, але це 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відсутності нуля в циклі може уповільнити програму. Без нього код все-таки був би правильним (оскільки маска 0 просто нічого не робить), і компілятору було б набагато простіше зробити цикл розгортання.


forне буде паралельно працювати, вас плутають for_each?
LTPCGO

3
@LTPCGO Ні, я не маю наміру паралелізувати це для циклу, це фактично порушило б алгоритм. Але цей код працює на різних 8-бітних цілих чисел у 64-бітовому цілому цілому паралельно, тобто всі 8 віднімання виконуються одночасно, але їм потрібно до 8 кроків.
n314159

Я розумію, що я просив, можливо, було трохи нерозумно, але це було досить близько до того, що мені потрібно було дякувати :)
cam-white

4
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 ++ незалежно від того, як хтось стикається з цим


5
Це, однак, не паралелізує роботу, що є питанням ОП.
nickelpro

Так @nickelpro вірно, це робило б кожне віднімання одне за одним, я хотів би відняти всі 8-бітні цілі числа одночасно. Я ціную відповідь Тхо спасибі братан
кулачковий-білий

2
@nickelpro, коли я розпочав відповідь, редагування не було зроблено, де було вказано паралельну частину питання, і тому я не помітив його до моменту подання, не залишиться, якщо це корисно для інших, оскільки він принаймні відповідає на частина робити побітові операції, і це могло б змусити працювати паралельно, використовуючи for_each(std::execution::par_unseq,...замість
батогів

2
Це моє погано, я подав питання, тоді зрозумів, що не сказав, що потрібно паралельно так редагувати
cam-white

2

Не збираєтесь придумувати код, але для зменшення на 1 ви могли б зменшити групу 8 1, а потім перевірити, щоб переконатися, що LSB результати отримали "перевернуті". Будь-який LSB, який не перемикав, вказує, що перенос відбувся із сусідніх 8 біт. Для цього вдається спрацювати послідовність AND / ORs / XOR без будь-яких гілок.


Це може спрацювати, але розглянемо випадок, коли перенесення поширюється весь шлях через одну групу з 8 біт і в іншу. Стратегія в хороших відповідях (встановити MSB або щось перше), щоб переконатись, що це не поширюється, є, мабуть, принаймні настільки ефективною, як це могло бути. Поточна мета, яку потрібно обіграти (тобто хороші відповіді без розгалуження без гілок) - це 5 інструкцій АЛУ RISC-V ASM з паралелізмом рівня інструкцій, що робить критичний шлях лише 3 цикли та використовує два 64-бітні константи.
Пітер Кордес

0

Сфокусуйте роботу над кожним байтом повністю поодинці, а потім поставте його назад, де він був.

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;
   }
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.