Sottraendo numeri interi compressi a 8 bit in un numero intero a 64 bit per 1 in parallelo, SWAR senza SIMD hardware


77

Se ho un numero intero a 64 bit che sto interpretando come un array di numeri interi a 8 bit compressi con 8 elementi. Devo sottrarre la costante 1da ogni intero compresso durante la gestione dell'overflow senza che il risultato di un elemento influisca sul risultato di un altro elemento.

Al momento ho questo codice e funziona, ma ho bisogno di una soluzione che esegua la sottrazione di ogni intero compresso a 8 bit in parallelo e non acceda alla memoria. Su x86 potrei usare istruzioni SIMD come quelle psubbche sottraggono numeri interi a 8 bit in parallelo ma la piattaforma per cui sto codificando non supporta le istruzioni SIMD. (RISC-V in questo caso).

Quindi sto provando a fare SWAR (SIMD all'interno di un registro) per annullare manualmente la propagazione carry tra byte di a uint64_t, facendo qualcosa di equivalente a questo:

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

Penso che potresti farlo con operatori bit per bit ma non ne sono sicuro. Sto cercando una soluzione che non usi le istruzioni SIMD. Sto cercando una soluzione in C o C ++ che sia abbastanza portatile o solo la teoria alla base in modo da poter implementare la mia soluzione.


5
Devono essere a 8 bit o potrebbero invece essere a 7 bit?
Tadman,

Devono essere dispiaciuti di 8 bit :(
bianco cam

12
Le tecniche per questo genere di cose sono chiamate SWAR
harold


1
ti aspetti che un byte contenga zero da racchiudere in 0xff?
Alnitak,

Risposte:


75

Se si dispone di una CPU con istruzioni SIMD efficienti, è possibile utilizzare anche SSE / MMX paddb( _mm_add_epi8). La risposta di Peter Cordes descrive anche la sintassi vettoriale GNU C (gcc / clang) e la sicurezza per UB alias rigoroso. Incoraggio vivamente anche a rivedere quella risposta.

Farlo da soli uint64_tè completamente portatile, ma richiede comunque attenzione per evitare problemi di allineamento e UB rigorosamente alias quando si accede a un uint8_tarray con a uint64_t*. Hai lasciato quella parte fuori questione iniziando con i tuoi dati in un uint64_tgià, ma per GNU C un may_aliastypedef risolve il problema (vedi la risposta di Peter per quello o memcpy).

Altrimenti potresti allocare / dichiarare i tuoi dati uint64_te accedervi uint8_t*quando vuoi singoli byte. unsigned char*è consentito alias qualsiasi cosa in modo da evitare il problema per il caso specifico degli elementi a 8 bit. (Se uint8_tesiste affatto, probabilmente è sicuro supporre che sia un unsigned char.)


Si noti che si tratta di una modifica rispetto a un algoritmo errato precedente (consultare la cronologia delle revisioni).

Ciò è possibile senza loop per sottrazione arbitraria e diventa più efficiente per una costante nota come 1in ogni byte. Il trucco principale consiste nell'impedire l'esecuzione da ciascun byte impostando il bit alto, quindi correggere il risultato della sottrazione.

Ottimizzeremo leggermente la tecnica di sottrazione qui fornita . Definiscono:

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

con Hdefinito come 0x8080808080808080U(ovvero gli MSB di ciascun intero compresso). Per un decremento, yè 0x0101010101010101U.

Sappiamo che yha tutti i suoi MSB chiari, quindi possiamo saltare uno dei passaggi della maschera (cioè y & ~Hè lo stesso ydel nostro caso). Il calcolo procede come segue:

  1. Impostiamo gli MSB di ciascun componente su x1, in modo che un prestito non possa propagarsi oltre l'MSB al componente successivo. Chiamalo input regolato.
  2. Sottraiamo 1 da ciascun componente, sottraendo 0x01010101010101dall'input corretto. Ciò non causa prestiti intercomponenti grazie al passaggio 1. Chiamare questo output regolato.
  3. Dobbiamo ora correggere l'MSB del risultato. Eseguiamo l'output regolato con gli MSB invertiti dell'input originale per terminare la correzione del risultato.

L'operazione può essere scritta come:

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

Preferibilmente, questo è sottolineato dal compilatore (usa le direttive del compilatore per forzarlo), o l'espressione è scritta in linea come parte di un'altra funzione.

Casi test:

in:  0000000000000000
out: ffffffffffffffff

in:  f200000015000013
out: f1ffffff14ffff12

in:  0000000000000100
out: ffffffffffff00ff

in:  808080807f7f7f7f
out: 7f7f7f7f7e7e7e7e

in:  0101010101010101
out: 0000000000000000

Dettagli sulle prestazioni

Ecco l'assembly x86_64 per una singola chiamata della funzione. Per una migliore prestazione, dovrebbe essere sottolineato con la speranza che le costanti possano vivere in un registro il più a lungo possibile. In un ciclo stretto in cui le costanti vivono in un registro, il decremento effettivo richiede cinque istruzioni: o + non + e + aggiungi + xo dopo l'ottimizzazione. Non vedo alternative che potrebbero battere l'ottimizzazione del compilatore.

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

Con alcuni test IACA del seguente frammento:

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

possiamo mostrare che su una macchina Skylake, l'esecuzione di decremento, xor e confronto + salto può essere eseguita a meno di 5 cicli per iterazione:

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

(Ovviamente, su x86-64 avresti semplicemente caricato o movqinserito un registro XMM per paddb, quindi potrebbe essere più interessante vedere come si compila per un ISA come RISC-V.)


4
Ho bisogno che il mio codice sia eseguito su macchine RISC-V che non hanno (ancora) istruzioni SIMD e tanto meno supporto per MMX
cam-white

2
@ cam-white Capito - questo è probabilmente il meglio che puoi fare allora. Salirò su godbolt per controllare anche l'assemblea per RISC. Modifica: Nessun supporto RISC-V su godbolt :(
nanofarad

7
In realtà esiste il supporto RISC-V su godbolt, ad esempio in questo modo (E: sembra che il compilatore diventi eccessivamente creativo nella creazione della maschera ..)
Harold

4
Ulteriori letture su come il trucco di parità (chiamato anche "vettore di trascinamento") può essere utilizzato in varie situazioni: emulators.com/docs/LazyOverflowDetect_Final.pdf
jpa

4
Ho fatto un'altra modifica; I vettori nativi GNU C in realtà evitano problemi di aliasing rigoroso; un vettore di uint8_tè autorizzato ad alias uint8_tdati. I chiamanti della tua funzione (che devono uint8_tinserire i dati in a uint64_t) sono quelli che devono preoccuparsi del rigoroso aliasing! Quindi probabilmente l'OP dovrebbe semplicemente dichiarare / allocare array come uint64_tperché char*è autorizzato ad alias qualcosa in ISO C ++, ma non viceversa.
Peter Cordes,

16

Per RISC-V probabilmente stai usando GCC / clang.

Curiosità: GCC conosce alcuni di questi trucchi SWAR bithack (mostrati in altre risposte) e li può usare per te durante la compilazione di codice con vettori nativi GNU C per target senza istruzioni SIMD hardware. (Ma clang per RISC-V lo srotolerà ingenuamente in operazioni scalari, quindi devi farlo da solo se vuoi buone prestazioni tra i compilatori).

Uno dei vantaggi della sintassi vettoriale nativa è che quando si sceglie come target una macchina con hardware SIMD, la utilizzerà invece di vettorializzare automaticamente il tuo bithack o qualcosa di orribile come quello.

Semplifica le vector -= scalaroperazioni di scrittura ; la sintassi Just Works, che trasmette implicitamente aka splattando lo scalare per te.


Si noti inoltre che un uint64_t*carico da auint8_t array[] UB è aliasing rigoroso, quindi fai attenzione. (Vedi anche Perché lo strlen di glibc deve essere così complicato da eseguire rapidamente? Re: rendere i bithack SWAR aliasing rigoroso sicuri nella C pura). Potresti volere qualcosa del genere per dichiarare uint64_tche puoi usare il puntatore-cast per accedere a qualsiasi altro oggetto, come il char*funzionamento in ISO C / C ++.

usali per ottenere i dati uint8_t in uint64_t per usarli con altre risposte:

// 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)));

L'altro modo per eseguire carichi aliasing-safe è con memcpyauint64_t , che rimuove anche il alignof(uint64_t) requisito di allineamento. Ma sugli ISA senza carichi non allineati efficienti, gcc / clang non memcpysi allineano e non ottimizzano quando non possono provare che il puntatore è allineato, il che sarebbe disastroso per le prestazioni.

TL: DR: la soluzione migliore è dichiarare i tuoi datiuint64_t array[...] o allocarli dinamicamente come uint64_t, o preferibilmentealignas(16) uint64_t array[]; che garantisce l'allineamento ad almeno 8 byte, o 16 se specificato alignas.

Poiché uint8_tè quasi certamente unsigned char*, è sicuro accedere ai byte di una uint64_tvia uint8_t*(ma non viceversa per una matrice uint8_t). Quindi, per questo caso speciale in cui si trova il tipo di elemento strettounsigned char , è possibile eludere il problema di alias rigoroso perché charè speciale.


Esempio di sintassi vettoriale nativo GNU C:

I vettori nativi GNU C sono sempre autorizzati ad alias con il loro tipo sottostante (ad es. int __attribute__((vector_size(16)))Alias ​​sicuro intma no floatouint8_t o altro.

#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.
}

Per RISC-V senza alcun SIMD HW, è possibile utilizzare vector_size(8)per esprimere solo la granularità che è possibile utilizzare in modo efficiente e fare il doppio dei vettori più piccoli.

Ma vector_size(8) compila molto stupidamente per x86 sia con GCC che con clang: GCC usa i bithack SWAR nei registri di numeri interi GP, clang decomprime a elementi a 2 byte per riempire un registro XMM a 16 byte, quindi reimballa. (MMX è così obsoleto che GCC / clang non si preoccupano nemmeno di usarlo, almeno non per x86-64.)

Ma con vector_size (16)( Godbolt ) otteniamo l'atteso movdqa/ paddb. (Con un vettore tutti generati da pcmpeqd same,same). Con-march=skylake abbiamo ancora due operazioni XMM separate invece di una YMM, quindi sfortunatamente anche i compilatori attuali non "auto-vettorizzano" le operazioni vettoriali in vettori più ampi: /

Per AArch64, non è poi così male da usare vector_size(8)( Godbolt ); ARM / AArch64 può funzionare nativamente in blocchi di 8 o 16 byte con do qregistri.

Quindi probabilmente vuoi vector_size(16)davvero compilare se vuoi prestazioni portatili su x86, RISC-V, ARM / AArch64 e POWER . Tuttavia, alcuni altri ISA fanno SIMD all'interno di registri interi a 64 bit, come penso MIPS MSA.

vector_size(8)rende più facile guardare l'asm (solo un registro di dati): Godbolt compiler explorer

# 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

Penso che sia la stessa idea di base delle altre risposte non cicliche; impedendo il trasporto quindi fissando il risultato

Queste sono 5 istruzioni ALU, peggio della risposta principale che penso. Ma sembra che la latenza del percorso critico sia di soli 3 cicli, con due catene di 2 istruzioni ciascuna che portano allo XOR. La risposta di @Reinstate Monica - ζ - viene compilata in una catena di dep a 4 cicli (per x86). Il throughput del ciclo a 5 cicli è strozzato includendo anche un ingenuosub nel percorso critico e il ciclo esegue il collo di bottiglia alla latenza.

Tuttavia, questo è inutile con il clang. Non aggiunge nemmeno e memorizza nello stesso ordine in cui è stato caricato, quindi non sta nemmeno facendo una buona pipeline di software!

# 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

Vorrei sottolineare che il codice che hai scritto in realtà vettorializza una volta che inizi a trattare con più di un singolo uint64_t.

https://godbolt.org/z/J9DRzd


1
Potresti spiegare o dare un riferimento a ciò che sta accadendo lì? Sembra piuttosto interessante.
n314159

2
Stavo provando a farlo senza istruzioni SIMD ma ho trovato questo interessante comunque :)
bianco cam

8
D'altra parte, quel codice SIMD è terribile. Il compilatore ha completamente frainteso ciò che sta accadendo qui. E: è un esempio di "questo è stato chiaramente fatto da un compilatore perché nessun essere umano sarebbe così stupido"
Harold

1
@PeterCordes: stavo pensando di più sulla falsariga di un __vector_loop(index, start, past, pad)costrutto che un'implementazione potrebbe trattare come for(index=start; index<past; index++)[il che significa che qualsiasi implementazione potrebbe elaborare il codice utilizzandolo, semplicemente definendo una macro], ma che avrebbe una semantica più libera per invitare un compilatore a elaborare le cose in qualsiasi dimensione del blocco di potenza di due fino a pad, estendendo l'inizio verso il basso e finendo verso l'alto se non sono già multipli della dimensione del blocco. Gli effetti collaterali all'interno di ogni blocco sarebbero senza conseguenze, e se si breakverifica all'interno del ciclo, altre ripetizioni ...
Supercat

1
@PeterCordes: Sebbene restrictsia utile (e sarebbe più utile se lo Standard riconoscesse il concetto di "almeno potenzialmente basato su", e quindi definito "basato su" e "almeno potenzialmente basato su" direttamente senza sciocchi e casi angusti non realizzabili) la mia proposta consentirebbe anche a un compilatore di eseguire più esecuzioni del ciclo di quanto richiesto, cosa che semplificherebbe notevolmente la vettorializzazione, ma per la quale lo standard non prevede.
Supercat

11

Puoi assicurarti che la sottrazione non trabocchi e quindi aggiustare il bit alto:

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

Penso che funzioni per tutti i 256 possibili valori di un byte; L'ho messo su Godbolt (con RISC-V clang) godbolt.org/z/DGL9aq per esaminare i risultati di propagazione costante per vari input come 0x0, 0x7f, 0x80 e 0xff (spostati al centro del numero). Sembra buono. Penso che la risposta migliore si riduca alla stessa cosa, ma la spiega in un modo più complicato.
Peter Cordes

I compilatori potrebbero fare un lavoro migliore costruendo costanti nei registri qui. clang spende molte istruzioni per costruire splat(0x01)e splat(0x80), invece di ottenere l'una dall'altra con un turno. Anche scrivendolo in questo modo nella fonte godbolt.org/z/6y9v-u non tiene in mano il compilatore per creare codice migliore; fa solo propagazione costante.
Peter Cordes

Mi chiedo perché non carica solo la costante dalla memoria; questo è ciò che fanno i compilatori per Alpha (un'architettura simile).
Falk Hüffner

GCC per RISC-V fa costanti carico dalla memoria. Sembra che il clang abbia bisogno di qualche ottimizzazione, a meno che non ci si aspettino errori nella cache dei dati e siano costosi rispetto al throughput delle istruzioni. (Tale equilibrio può sicuramente essere cambiato da Alpha, e presumibilmente diverse implementazioni di RISC-V sono diverse. I compilatori potrebbero anche fare molto meglio se si rendessero conto che si trattava di un modello ripetitivo che potrebbero spostare / O per ampliare dopo aver iniziato con una LUI / aggiungi per 20 + 12 = 32 bit di dati immediati. I bit-pattern immediati di AArch64 potrebbero persino usarli come immediati per AND / OR / XOR, decodifica intelligente vs. scelta della densità)
Peter Cordes

Aggiunta una risposta che mostra lo SWAR vettoriale nativo di GCC per RISC-V
Peter Cordes,

7

Non sono sicuro se questo è quello che vuoi ma fa le 8 sottrazioni parallele tra loro:

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

Spiegazione: La maschera di bit inizia con un 1 in ciascuno dei numeri a 8 bit. Lo sosteniamo con il nostro argomento. Se avessimo un 1 in questo posto, abbiamo sottratto 1 e dobbiamo fermarci. Questo viene fatto impostando il bit corrispondente su 0 in new_mask. Se avessimo uno 0, lo impostiamo su 1 e dobbiamo fare il carry, quindi il bit rimane 1 e spostiamo la maschera a sinistra. Faresti meglio a verificare se la generazione della nuova maschera funziona come previsto, penso di sì, ma una seconda opinione non sarebbe male.

PS: In realtà non sono sicuro che il controllo sul mask_cpnon essere nullo nel loop possa rallentare il programma. Senza di esso, il codice sarebbe comunque corretto (dal momento che la maschera 0 non fa nulla) e sarebbe molto più facile per il compilatore eseguire lo svolgimento di cicli.


fornon correrà in parallelo, sei confuso con for_each?
LTPCGO

3
@LTPCGO No, non è mia intenzione parallelizzare questo per loop, questo in realtà romperebbe l'algoritmo. Ma questo codice funziona sui diversi numeri interi a 8 bit nell'intero a 64 bit in parallelo, ovvero tutte e 8 le sottrazioni vengono eseguite simultaneamente ma richiedono fino a 8 passaggi.
n314159

Mi rendo conto che quello che stavo chiedendo avrebbe potuto essere un po 'irragionevole, ma questo era abbastanza vicino a quello di cui avevo bisogno grazie :)
bianco cam

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
} 

Puoi farlo con operazioni bit a bit usando quanto sopra, e devi solo dividere il tuo intero in pezzi a 8 bit per inviare 8 volte in questa funzione. La parte seguente è stata presa da Come dividere un numero a 64 bit in otto valori a 8 bit? con me aggiungendo la funzione sopra

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

È valido C o C ++ indipendentemente da come qualcuno si imbatte in questo


5
Ciò tuttavia non parallelizza il lavoro, che è la domanda di OP.
Nickelpro

Sì, @nickelpro ha ragione, questo farebbe ogni sottrazione una dopo l'altra, vorrei sottrarre tutti i numeri interi a 8 bit contemporaneamente. Apprezzo la risposta tho grazie bro
cam-white

2
@nickelpro quando ho iniziato la risposta non era stata effettuata la modifica che indicava la parte parallela della domanda e quindi non l'ho notato fino a dopo l'invio, lascerà nel caso in cui sia utile per gli altri in quanto almeno risponde al parte per fare operazioni bit per bit e potrebbe essere fatto funzionare in parallelo utilizzando for_each(std::execution::par_unseq,...invece di whiles
LTPCGO

2
È un peccato, ho inviato la domanda e poi ho capito che non avevo bisogno di essere in parallelo, quindi modificato
bianco cam

2

Non proverai a trovare il codice, ma per un decremento di 1 potresti decrementare del gruppo di 8 1 e quindi verificare che gli LSB dei risultati siano "capovolti". Qualsiasi LSB che non è stato attivato indica che si è verificato un carry dagli 8 bit adiacenti. Dovrebbe essere possibile elaborare una sequenza di AND / OR / XOR per gestirlo, senza rami.


Ciò potrebbe funzionare, ma considera il caso in cui un carry si propaga fino in fondo attraverso un gruppo di 8 bit e in un altro. La strategia nelle buone risposte (di impostare prima l'MSB o qualcosa del genere) per garantire che il carry non si propaga è probabilmente almeno altrettanto efficace di quanto potrebbe essere. Il target attuale da battere (ovvero le buone risposte branchless senza loop) sono 5 istruzioni RISC-V asm ALU con parallelismo a livello di istruzione che rende il percorso critico a soli 3 cicli e utilizzando due costanti a 64 bit.
Peter Cordes,

0

Concentrare il lavoro su ogni byte da solo, quindi rimetterlo dove si trovava.

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;
   }
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.