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.
Respuestas:
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_t
es totalmente portátil, pero aún requiere cuidado para evitar problemas de alineación y UB de alias estricto al acceder a unauint8_t
matriz con auint64_t*
. Dejó esa parte fuera de la pregunta comenzando con sus datos en unuint64_t
ya, pero para GNU C unmay_alias
typedef resuelve el problema (vea la respuesta de Peter para eso omemcpy
).De lo contrario, podría asignar / declarar sus datos como
uint64_t
y acceder a ellosuint8_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. (Siuint8_t
existe, probablemente sea seguro asumir que es ununsigned 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
1
en 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:
con
H
definido como0x8080808080808080U
(es decir, los MSB de cada entero empaquetado). Por decremento,y
es0x0101010101010101U
.Sabemos que
y
tiene todos sus MSB claros, por lo que podemos omitir uno de los pasos de la máscara (y & ~H
es decir, es el mismo quey
en nuestro caso). El cálculo se realiza de la siguiente manera:x
a 1, de modo que un préstamo no pueda propagarse más allá del MSB al siguiente componente. Llame a esto la entrada ajustada.0x01010101010101
de la entrada corregida. Esto no causa préstamos entre componentes gracias al paso 1. Llame a esto la salida ajustada.La operación se puede escribir como:
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:
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.
Con algunas pruebas de IACA del siguiente fragmento:
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:
(Por supuesto, en x86-64 solo cargaría o
movq
en un registro XMMpaddb
, por lo que podría ser más interesante ver cómo se compila para un ISA como RISC-V).fuente
uint8_t
se permite un vector de alias deuint8_t
datos. ¡Las personas que llaman a su función (que necesitan ingresaruint8_t
datos en auint64_t
) son las que deben preocuparse por el alias estricto! Por lo tanto, es probable que el OP simplemente declare / asigne matricesuint64_t
porquechar*
está permitido alias cualquier cosa en ISO C ++, pero no al revés.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 -= scalar
operaciones; 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 unuint8_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 declararuint64_t
que 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:
La otra forma de hacer cargas seguras de alias es con
memcpy
auint64_t
, que también elimina elalignof(uint64_t
requisito de alineación. Pero en los ISA sin cargas no alineadas eficientes, gcc / clang nomemcpy
se 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 como
uint64_t array[...]
o asignarlos dinámicamente comouint64_t
, o preferiblementealignas(16) uint64_t array[];
Eso asegura la alineación de al menos 8 bytes, o 16 si especificaalignas
.Como
uint8_t
es casi segurounsigned char*
, es seguro acceder a los bytes de unauint64_t
víauint8_t*
(pero no viceversa para una matriz uint8_t). Entonces, para este caso especial donde está el tipo de elemento estrechounsigned char
, puede eludir el problema de alias estricto porquechar
es 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 aliasint
pero nofloat
ouint8_t
o cualquier otra cosa.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 esperamovdqa
/paddb
. (Con un vector de todos generados porpcmpeqd same,same
). Con-march=skylake
todaví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 cond
oq
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 GodboltCreo 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 ingenuo
sub
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!
fuente
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
fuente
__vector_loop(index, start, past, pad)
construcción que una implementación podría tratar comofor(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 hastapad
, 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 sibreak
ocurre dentro del ciclo, otras repeticiones ...restrict
es ú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.Puede asegurarse de que la resta no se desborde y luego arreglar el bit alto:
fuente
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.No estoy seguro de si esto es lo que quiere, pero hace las 8 restas en paralelo entre sí:
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_cp
no 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.fuente
for
no funcionará en paralelo, ¿estás confundido confor_each
?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
Es válido C o C ++ independientemente de cómo alguien se encuentre con esto
fuente
for_each(std::execution::par_unseq,...
lugar de ratosNo 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.
fuente
Concentre el trabajo en cada byte completamente solo, luego vuelva a colocarlo donde estaba.
fuente