Restando enteros de 8 bits empaquetados en un entero de 64 bits por 1 en paralelo, SWAR sin SIMD de hardware

77

Si tengo un entero de 64 bits que estoy interpretando como una matriz de enteros de 8 bits con 8 elementos. Necesito restar la constante1 de cada entero empaquetado mientras manejo el desbordamiento sin que el resultado de un elemento afecte el resultado de otro elemento.

Tengo este código en este momento y funciona, pero necesito una solución que reste cada número entero de 8 bits empaquetado en paralelo y no haga accesos a la memoria. En x86 podría usar instrucciones SIMD comopsubb que restan enteros de 8 bits en paralelo, pero la plataforma para la que estoy codificando no admite instrucciones SIMD. (RISC-V en este caso).

Así que estoy tratando de hacer SWAR (SIMD dentro de un registro) para cancelar manualmente la propagación de transferencia entre bytes de a uint64_t, haciendo algo equivalente a esto:

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

Creo que podría hacer esto con operadores bit a bit, pero no estoy seguro. Estoy buscando una solución que no use las instrucciones SIMD. Estoy buscando una solución en C o C ++ que sea bastante portátil o simplemente la teoría detrás de ella para poder implementar mi propia solución.

blanco cam
fuente
55
¿Necesitan ser de 8 bits o podrían ser de 7 bits?
tadman
Tienen que ser 8-bit lo siento :(
cam-white
12
Las técnicas para este tipo de cosas se llaman SWAR
harold
1
¿espera que un byte contenga cero para ajustarse a 0xff?
Alnitak

Respuestas:

75

Si tiene una CPU con instrucciones SIMD eficientes, SSE / MMX paddb( _mm_add_epi8) también es viable. La respuesta de Peter Cordes también describe la sintaxis del vector GNU C (gcc / clang) y la seguridad para UB de alias estricto. Recomiendo encarecidamente revisar esa respuesta también.

Hacerlo usted mismo uint64_tes totalmente portátil, pero aún requiere cuidado para evitar problemas de alineación y UB de alias estricto al acceder a una uint8_tmatriz con a uint64_t*. Dejó esa parte fuera de la pregunta comenzando con sus datos en un uint64_tya, pero para GNU C un may_aliastypedef resuelve el problema (vea la respuesta de Peter para eso o memcpy).

De lo contrario, podría asignar / declarar sus datos como uint64_ty acceder a ellos uint8_t*cuando desee bytes individuales. unsigned char*se permite alias cualquier cosa para evitar el problema para el caso específico de elementos de 8 bits. (Si uint8_texiste, probablemente sea seguro asumir que es un unsigned char.)


Tenga en cuenta que este es un cambio de un algoritmo incorrecto anterior (consulte el historial de revisiones).

Esto es posible sin bucles para restas arbitrarias, y se vuelve más eficiente para una constante conocida como 1en cada byte. El truco principal es evitar la ejecución de cada byte configurando el bit alto y luego corregir el resultado de la resta.

Vamos a optimizar ligeramente la técnica de resta dada aquí . Ellos definen:

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

con Hdefinido como 0x8080808080808080U(es decir, los MSB de cada entero empaquetado). Por decremento, yes 0x0101010101010101U.

Sabemos que ytiene todos sus MSB claros, por lo que podemos omitir uno de los pasos de la máscara ( y & ~Hes decir, es el mismo que yen nuestro caso). El cálculo se realiza de la siguiente manera:

  1. Establecemos los MSB de cada componente de xa 1, de modo que un préstamo no pueda propagarse más allá del MSB al siguiente componente. Llame a esto la entrada ajustada.
  2. Restamos 1 de cada componente, restando 0x01010101010101 de la entrada corregida. Esto no causa préstamos entre componentes gracias al paso 1. Llame a esto la salida ajustada.
  3. Ahora necesitamos corregir el MSB del resultado. Para completar el resultado, modificamos la salida ajustada con los MSB invertidos de la entrada original.

La operación se puede escribir como:

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

Preferiblemente, esto está en línea por el compilador (use las directivas del compilador para forzar esto), o la expresión se escribe en línea como parte de otra función.

Casos de prueba:

in:  0000000000000000
out: ffffffffffffffff

in:  f200000015000013
out: f1ffffff14ffff12

in:  0000000000000100
out: ffffffffffff00ff

in:  808080807f7f7f7f
out: 7f7f7f7f7e7e7e7e

in:  0101010101010101
out: 0000000000000000

Detalles de rendimiento

Aquí está el ensamblado x86_64 para una sola invocación de la función. Para un mejor rendimiento, debe estar alineado con la esperanza de que las constantes puedan vivir en un registro el mayor tiempo posible. En un ciclo cerrado donde las constantes viven en un registro, la disminución real toma cinco instrucciones: o + not + y + add + xor después de la optimización. No veo alternativas que superen la optimización del compilador.

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 algunas pruebas de IACA del siguiente fragmento:

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

podemos mostrar que en una máquina Skylake, la ejecución de la disminución, xor y comparar + salto se puede realizar a menos de 5 ciclos por iteración:

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

(Por supuesto, en x86-64 solo cargaría o movqen un registro XMM paddb, por lo que podría ser más interesante ver cómo se compila para un ISA como RISC-V).

nanofaradio
fuente
44
Necesito que mi código se ejecute en máquinas RISC-V que aún no tienen instrucciones SIMD (y mucho menos), y mucho menos soporte para MMX
cam-white
2
@ cam-white Entendido, esto es probablemente lo mejor que puedes hacer entonces. Saltaré a Godbolt para comprobar la cordura de la asamblea para RISC también. Editar: No hay soporte RISC-V en godbolt :(
nanofarad
77
En realidad, hay soporte RISC-V en godbolt, por ejemplo de esta manera (E: parece que el compilador se vuelve demasiado creativo al crear la máscara ...)
harold
44
Lectura adicional sobre cómo el truco de paridad (también llamado "vector de ejecución") se puede utilizar en diversas situaciones: emulators.com/docs/LazyOverflowDetect_Final.pdf
jpa
44
Hice otra edición; Los vectores nativos de GNU C en realidad evitan problemas de alias estricto; uint8_tse permite un vector de alias de uint8_tdatos. ¡Las personas que llaman a su función (que necesitan ingresar uint8_tdatos en a uint64_t) son las que deben preocuparse por el alias estricto! Por lo tanto, es probable que el OP simplemente declare / asigne matrices uint64_tporque char*está permitido alias cualquier cosa en ISO C ++, pero no al revés.
Peter Cordes
16

Para RISC-V probablemente estés usando GCC / clang.

Dato curioso: GCC conoce algunos de estos trucos de bithack SWAR (que se muestran en otras respuestas) y puede usarlos para usted cuando compila código con vectores nativos GNU C para objetivos sin instrucciones SIMD de hardware. (Pero el sonido metálico para RISC-V lo desenrollará ingenuamente a operaciones escalares, por lo que debe hacerlo usted mismo si desea un buen rendimiento en los compiladores).

Una ventaja de la sintaxis de vectores nativos es que cuando se dirige a una máquina con SIMD de hardware, la usará en lugar de vectorizar automáticamente su bithack o algo horrible como eso.

Facilita la escritura de vector -= scalaroperaciones; la sintaxis Just Works, transmitiendo implícitamente también conocido como salpicando el escalar por usted.


También tenga en cuenta que una uint64_t*carga de un uint8_t array[]UB es de alias estricto, así que tenga cuidado con eso. (Ver también ¿Por qué la strlen de glibc debe ser tan complicada para ejecutarse rápidamente? Re: hacer que los bithacks SWAR sean seguros con alias estricto en C puro). Es posible que desee algo como esto para declarar uint64_tque puede lanzar puntero para acceder a cualquier otro objeto, como cómochar* funciona en ISO C / C ++.

úselos para obtener datos de uint8_t en un uint64_t para usar con otras respuestas:

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

La otra forma de hacer cargas seguras de alias es con memcpya uint64_t, que también elimina el alignof(uint64_trequisito de alineación. Pero en los ISA sin cargas no alineadas eficientes, gcc / clang no memcpyse alinea y optimiza cuando no pueden probar que el puntero está alineado, lo que sería desastroso para el rendimiento.

TL: DR: su mejor opción es declarar sus datos comouint64_t array[...] o asignarlos dinámicamente como uint64_t, o preferiblementealignas(16) uint64_t array[]; Eso asegura la alineación de al menos 8 bytes, o 16 si especificaalignas .

Como uint8_tes casi seguro unsigned char*, es seguro acceder a los bytes de una uint64_tvía uint8_t*(pero no viceversa para una matriz uint8_t). Entonces, para este caso especial donde está el tipo de elemento estrecho unsigned char, puede eludir el problema de alias estricto porque chares especial.


Ejemplo de sintaxis de vector nativo de GNU C:

Vectores nativos GNU C siempre se permite a los alias con su tipo subyacente (por ejemplo, int __attribute__((vector_size(16)))puede de manera segura alias intpero no floato uint8_to cualquier otra cosa.

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

Para RISC-V sin HW SIMD, puede usar vector_size(8) para expresar solo la granularidad que puede usar de manera eficiente y hacer el doble de vectores más pequeños.

Pero vector_size(8)compila muy estúpidamente para x86 tanto con GCC como con clang: GCC usa bithacks SWAR en registros GP-enteros, clang desempaqueta a elementos de 2 bytes para llenar un registro XMM de 16 bytes y luego los vuelve a empaquetar. (MMX es tan obsoleto que GCC / clang ni siquiera se molestan en usarlo, al menos no para x86-64).

Pero con vector_size (16)( Godbolt ) obtenemos la espera movdqa/ paddb. (Con un vector de todos generados por pcmpeqd same,same). Con -march=skylaketodavía obtenemos dos operaciones XMM separadas en lugar de una YMM, por lo que desafortunadamente los compiladores actuales tampoco "vectorizan automáticamente" las operaciones vectoriales en vectores más amplios: /

Para AArch64, no es tan malo usar vector_size(8)( Godbolt ); ARM / AArch64 puede trabajar de forma nativa en fragmentos de 8 o 16 bytes con doq registros.

Por lo tanto, es probable que desee vector_size(16)compilar si desea un rendimiento portátil en x86, RISC-V, ARM / AArch64 y POWER . Sin embargo, algunos otros ISA hacen SIMD dentro de registros enteros de 64 bits, como MIPS MSA, creo.

vector_size(8)hace que sea más fácil mirar el asm (solo un registro de datos): el explorador del compilador 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

Creo que es la misma idea básica que las otras respuestas sin bucle; evitando llevar y luego arreglando el resultado.

Estas son 5 instrucciones ALU, peor que la respuesta principal, creo. Pero parece que la latencia de ruta crítica es de solo 3 ciclos, con dos cadenas de 2 instrucciones cada una que conduce al XOR. @Reinstale las respuestas de Monica - ζ - a una cadena de dep de 4 ciclos (para x86). El rendimiento del ciclo de 5 ciclos tiene un cuello de botella al incluir también un ingenuosub en la ruta crítica, y el ciclo tiene un cuello de botella en la latencia.

Sin embargo, esto es inútil con el sonido metálico. ¡Ni siquiera agrega y almacena en el mismo orden en que se cargó, por lo que ni siquiera está haciendo una buena canalización de 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
Peter Cordes
fuente
13

Señalaría que el código que ha escrito en realidad se vectoriza una vez que comienza a tratar con más de un uint64_t.

https://godbolt.org/z/J9DRzd

robthebloke
fuente
1
¿Podría explicar o dar una referencia a lo que está sucediendo allí? Parece bastante interesante
n314159
2
Estaba tratando de hacer esto sin instrucciones SIMD, pero me pareció interesante, no obstante :)
cam-white
8
Por otro lado, ese código SIMD es horrible. El compilador entendió mal lo que está sucediendo aquí. E: es un ejemplo de "esto fue hecho claramente por un compilador porque ningún humano sería tan estúpido"
Harold
1
@PeterCordes: Estaba pensando más en la línea de una __vector_loop(index, start, past, pad)construcción que una implementación podría tratar como for(index=start; index<past; index++)[lo que significa que cualquier implementación podría procesar el código usándolo, simplemente definiendo una macro], pero que tendría una semántica más flexible para invitar a un compilador a procesar cosas en cualquier tamaño de fragmento de potencia de dos hasta pad, extendiendo el inicio hacia abajo y el final hacia arriba si aún no son múltiplos del tamaño del fragmento. Los efectos secundarios dentro de cada fragmento no tendrían secuencia, y si breakocurre dentro del ciclo, otras repeticiones ...
supercat
1
@PeterCordes: Mientras que restrictes útil (y sería más útil si el Estándar reconociera un concepto de "al menos potencialmente basado en", y luego definido "basado en" y "al menos potencialmente basado en" directamente sin casos de esquina tontos e inviables) mi propuesta también permitiría que un compilador realice más ejecuciones del ciclo de lo solicitado, algo que simplificaría en gran medida la vectorización, pero para lo cual el Estándar no prevé nada.
supercat
11

Puede asegurarse de que la resta no se desborde y luego arreglar el 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;
}
Falk Hüffner
fuente
Creo que funciona para los 256 valores posibles de un byte; Lo puse en Godbolt (con RISC-V clang) godbolt.org/z/DGL9aq para ver los resultados de propagación constante de varias entradas como 0x0, 0x7f, 0x80 y 0xff (desplazado a la mitad del número). Se ve bien. Creo que la respuesta principal se reduce a lo mismo, pero lo explica de una manera más complicada.
Peter Cordes
Los compiladores podrían hacer un mejor trabajo construyendo constantes en los registros aquí. clang gasta muchas instrucciones construyendo splat(0x01)ysplat(0x80) , en lugar de obtener una de la otra con un turno. Incluso escribirlo de esa manera en la fuente godbolt.org/z/6y9v-u no ayuda al compilador a hacer un mejor código; solo hace propagación constante.
Peter Cordes
Me pregunto por qué no solo carga la constante de la memoria; eso es lo que hacen los compiladores para Alpha (una arquitectura similar).
Falk Hüffner
GCC para RISC-V hace constantes de carga de la memoria. Parece que el sonido metálico necesita un poco de ajuste, a menos que se esperen errores de caché de datos y sean costosos en comparación con el rendimiento de la instrucción. (Ese equilibrio ciertamente puede haber cambiado desde Alpha, y presumiblemente las diferentes implementaciones de RISC-V son diferentes. Los compiladores también podrían hacerlo mucho mejor si se dieran cuenta de que es un patrón repetitivo que podrían cambiar / OR para ampliar después de comenzar con un LUI / add para 20 + 12 = 32 bits de datos inmediatos. Los valores inmediatos de patrón de bits de AArch64 podrían incluso usarlos como elementos inmediatos para AND / OR / XOR, decodificación inteligente versus elección de densidad)
Peter Cordes
Se agregó una respuesta que muestra el SWAR de vector nativo de GCC para RISC-V
Peter Cordes
7

No estoy seguro de si esto es lo que quiere, pero hace las 8 restas en paralelo entre sí:

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

Explicación: La máscara de bits comienza con un 1 en cada uno de los números de 8 bits. Lo hacemos con nuestro argumento. Si tenemos un 1 en este lugar, restamos 1 y tenemos que parar. Esto se hace estableciendo el bit correspondiente a 0 en new_mask. Si teníamos un 0, lo establecemos en 1 y tenemos que hacer el carry, por lo que el bit permanece en 1 y desplazamos la máscara hacia la izquierda. Es mejor que compruebe usted mismo si la generación de la nueva máscara funciona según lo previsto, creo, pero una segunda opinión no sería mala.

PD: en realidad no estoy seguro si la verificación de mask_cpno ser nula en el ciclo puede ralentizar el programa. Sin él, el código seguiría siendo correcto (ya que la máscara 0 simplemente no hace nada) y sería mucho más fácil para el compilador desenrollar el bucle.

n314159
fuente
forno funcionará en paralelo, ¿estás confundido con for_each?
LTPCGO
3
@LTPCGO No, no es mi intención paralelizar esto para el bucle, esto realmente rompería el algoritmo. Pero este código funciona en los diferentes enteros de 8 bits en el entero de 64 bits en paralelo, es decir, las 8 sustracciones se realizan simultáneamente, pero necesitan hasta 8 pasos.
n314159
Me doy cuenta de que lo que estaba preguntando podría haber sido un poco irracional, pero esto fue bastante cercano a lo que necesitaba, gracias :)
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
} 

Puede hacerlo con operaciones bit a bit utilizando lo anterior, y solo tiene que dividir su número entero en piezas de 8 bits para enviar 8 veces a esta función. La siguiente parte fue tomada de ¿Cómo dividir un número de 64 bits en ocho valores de 8 bits? conmigo agregando en la función anterior

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

Es válido C o C ++ independientemente de cómo alguien se encuentre con esto

LTPCGO
fuente
55
Sin embargo, esto no paraleliza el trabajo, que es la pregunta de OP.
nickelpro
Sí, @nickelpro tiene razón, esto haría cada resta una tras otra, me gustaría restar todos los enteros de 8 bits al mismo tiempo. Agradezco la respuesta aunque gracias hermano
cam-white
2
@nickelpro cuando comencé la respuesta, la edición no se había hecho, lo que indicaba la parte paralela de la pregunta y, por lo tanto, no lo noté hasta después del envío, se suspenderá en caso de que sea útil para otros, ya que al menos responde parte para hacer operaciones bit a bit y se podría hacer que funcione en paralelo utilizando en for_each(std::execution::par_unseq,...lugar de ratos
LTPCGO
2
Es mi problema, presenté la pregunta y luego me di cuenta de que no había dicho que debía estar en paralelo, así que editada
cam-white
2

No voy a tratar de encontrar el código, pero para una disminución de 1 puede disminuir por el grupo de 8 1s y luego verificar para asegurarse de que los LSB de los resultados se hayan "invertido". Cualquier LSB que no se haya activado indica que se produjo un acarreo de los 8 bits adyacentes. Debería ser posible calcular una secuencia de AND / OR / XOR para manejar esto, sin ninguna rama.

Hot Licks
fuente
Eso podría funcionar, pero considere el caso en que un carry se propaga a través de un grupo de 8 bits y dentro de otro. La estrategia en las buenas respuestas (de establecer el MSB o algo primero) para garantizar que el carry no se propague es probablemente al menos tan eficiente como podría ser. El objetivo actual para vencer (es decir, las buenas respuestas sin bifurcación sin bucle) son 5 instrucciones ALU RISC-V asm con paralelismo de nivel de instrucción que hace que la ruta crítica solo sea 3 ciclos, y que use dos constantes de 64 bits.
Peter Cordes
0

Concentre el trabajo en cada byte completamente solo, luego vuelva a colocarlo donde estaba.

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