Función de piso entero eficiente en C++
performance x86-64 (5)
Casting a int es notoriamente lento.
Tal vez usted ha estado viviendo bajo una roca desde x86-64, o se ha perdido de alguna manera que esto no ha sido cierto durante un tiempo en x86. :)
SSE / SSE2 tiene una instrucción para convertir con truncamiento (en lugar del modo de redondeo predeterminado). La ISA admite esta operación de manera eficiente precisamente porque la conversión con semántica de C no es rara en las bases de código reales. El código x86-64 utiliza los registros XMM SSE / SSE2 para matemáticas FP escalares, no x87, debido a esto y otras cosas que lo hacen más eficiente. Incluso el código moderno de 32 bits utiliza registros XMM para matemáticas escalares.
Cuando se compila para x87 (sin la
fisttp
SSE3), los compiladores solían tener que cambiar el modo de redondeo x87 al truncamiento, el almacenamiento de FP a la memoria y luego volver a cambiar el modo de redondeo.
(Y luego vuelva a cargar el entero desde la memoria, generalmente desde un local en la pila, si está haciendo más cosas con él). X87 fue
terrible
para esto.
Sí, eso fue terriblemente lento, por ejemplo, en 2006, cuando se escribió el enlace en @ Kirjain, si todavía tenía una CPU de 32 bits o estaba usando una CPU x86-64 para ejecutar el código de 32 bits.
La conversión con un modo de redondeo distinto del truncamiento o el valor predeterminado (el más cercano) no se admite directamente, y hasta que las
roundps
roundpd
roundps
/
roundpd
SSE4.1
roundps
trucos de números mágicos, como en
el enlace de 2006
de @ Kirjain.
Algunos trucos agradables allí, pero solo para el
double
-> entero de 32 bits.
Es poco probable que valga la pena expandirse al
double
si tienes
float
.
O más generalmente, simplemente agregue un número de gran magnitud para desencadenar el redondeo, luego reste de nuevo para volver al rango original.
Esto puede funcionar para
float
sin expandirse al
double
, pero no estoy seguro de lo fácil que es hacer que el
floor
funcione.
De todos modos, la solución obvia aquí es
_mm256_floor_ps()
y
_mm256_cvtps_epi32
(
vroundps
y
vcvtps2dq
).
Una versión no AVX de esto puede funcionar con SSE4.1.
No estoy seguro de si podemos hacerlo aún mejor;
Si tuviera que procesar una gran matriz (y no pudo intercalar este trabajo con otro trabajo),
podría establecer el modo de redondeo MXCSR en "Hacia -Inf" (piso) y simplemente usar
vcvtps2dq
(que usa el modo de redondeo actual ).
Entonces ponlo de vuelta.
Pero es probable que sea mejor bloquear en caché la conversión o hacerlo sobre la marcha a medida que genera los datos, probablemente de otros cálculos de PF que necesitan que el modo de redondeo de PF esté configurado en el valor Más cercano predeterminado.
roundps
/ pd / ss / sd es 2 uops en las CPU Intel, pero solo 1 uop (por carril de 128 bits) en AMD Ryzen.
cvtps2dq
también es 1 uop.
empaquetado doble -> conversión int también incluye un shuffle.
La conversión escalar de FP-> int (que se copia a un registro entero) generalmente también cuesta un uop adicional para eso.
Así que hay espacio para la posibilidad de que los trucos con números mágicos sean una victoria en algunos casos;
quizás valga la pena investigar si
_mm256_floor_ps()
+ cvt es parte de un cuello de botella crítico (o más probablemente si tiene doble y quiere int32).
El
int foo = floorf(f)
Cássio Renan en realidad se auto-vectoriza si se compila con
gcc -O3 -fno-trapping-math
(o
-ffast-math
), con
-march=
algo que tiene SSE4.1 o AVX.
https://godbolt.org/z/ae_KPv
Esto puede ser útil si está usando esto con otro código escalar que no se ha vectorizado manualmente. Especialmente si esperas que el compilador auto-vectorice todo el asunto.
Quiero definir una función de piso de enteros eficiente, es decir, una conversión de float o double que realice el truncamiento hacia el infinito menos.
Podemos suponer que los valores son tales que no se produce un desbordamiento de enteros. Hasta ahora tengo algunas opciones.
-
casting a int; esto requiere un manejo especial de los valores negativos, ya que la conversión se trunca hacia cero;
I= int(F); if (I < 0 && I != F) I--;
-
echando el resultado del piso a int;
int(floor(F));
-
lanzar a int con un gran cambio para obtener resultados positivos (esto puede devolver resultados erróneos para valores grandes);
int(F + double(0x7fffffff)) - 0x7fffffff;
Casting a int es notoriamente lento. Así son si las pruebas. No he cronometrado la función de piso, pero he visto publicaciones que afirman que también es lenta.
¿Puedes pensar en mejores alternativas en términos de velocidad, precisión o rango permitido? No necesita ser portátil. Los objetivos son las arquitecturas recientes x86 / x64.
¿Por qué no usar esto?
#include <cmath>
auto floor_(float const x) noexcept
{
int const t(x);
return t - (t > x);
}
Aquí hay una modificación de la excelente respuesta de Cássio Renan. Reemplaza todas las extensiones específicas del compilador con C ++ estándar y es, en teoría, portátil a cualquier compilador conforme. Además, verifica que los argumentos estén alineados correctamente en lugar de suponerlo. Optimiza al mismo código.
#include <assert.h>
#include <cmath>
#include <stddef.h>
#include <stdint.h>
#define ALIGNMENT alignof(max_align_t)
using std::floor;
// Compiled with: -std=c++17 -Wall -Wextra -Wpedantic -Wconversion -fno-trapping-math -O -march=cannonlake -mprefer-vector-width=512
void testFunction(const float in[], int32_t out[], const ptrdiff_t length)
{
static_assert(sizeof(float) == sizeof(int32_t), "");
assert((uintptr_t)(void*)in % ALIGNMENT == 0);
assert((uintptr_t)(void*)out % ALIGNMENT == 0);
assert((size_t)length % (ALIGNMENT/sizeof(int32_t)) == 0);
alignas(ALIGNMENT) const float* const input = in;
alignas(ALIGNMENT) int32_t* const output = out;
// Do the conversion
for (int i = 0; i < length; ++i) {
output[i] = static_cast<int32_t>(floor(input[i]));
}
}
Esto no se optimiza tan bien en GCC como el original, que usaba extensiones no portátiles.
El estándar de C ++ admite un especificador
alignas
, referencias a matrices alineadas y una función
std::align
que devuelve un rango alineado dentro de un búfer.
Sin embargo, ninguno de estos, hace que el compilador que he probado genere alineados en lugar de cargas y almacenes vectoriales no alineados.
Aunque
alignof(max_align_t)
es solo 16 en x86_64, y es posible definir
ALIGNMENT
como la constante 64, esto no ayuda a ningún compilador a generar un mejor código, así que opté por la portabilidad.
Lo más parecido a una forma portátil de forzar al compilador a asumir que un poitner está alineado sería utilizar los tipos de
<immintrin.h>
, que la mayoría de los compiladores admiten x86, o definir una
struct
con un especificador
alignas
.
Al marcar macros predefinidas, también puede expandir una macro a
__attribute__ ((aligned (ALIGNMENT)))
en compiladores de Linux, o
__declspec (align (ALIGNMENT))
en compiladores de Windows, y algo seguro en un compilador que no conocemos, pero GCC necesita el atributo en un
tipo
para generar realmente cargas y almacenes alineados.
Además, el ejemplo original llamó a un bulit-in para decirle a GCC que era imposible que la
length
no fuera un múltiplo de 32. Si
assert()
esto o llama a una función estándar como
abort()
, ni GCC, Clang ni ICC Hará la misma deducción.
Por lo tanto, la mayoría del código que generan manejará el caso donde la
length
no sea un múltiplo redondo agradable del ancho del vector.
Una razón probable para esto es que ninguna optimización le proporciona tanta velocidad: las instrucciones de memoria no alineadas con direcciones alineadas son rápidas en las CPU de Intel, y el código para manejar el caso en el que la
length
no es un buen número redondo es de unos pocos bytes y se ejecuta en tiempo constante
Como nota al pie, GCC puede optimizar las funciones en línea desde
<cmath>
mejor que las macros implementadas en
<math.c>
.
GCC 9.1 necesita un conjunto particular de opciones para generar el código AVX512.
De manera predeterminada, incluso con
-march=cannonlake
, preferirá vectores de 256 bits.
Necesita el
-mprefer-vector-width=512
para generar un código de 512 bits.
(Gracias a Peter Cordes por señalar esto). Sigue el bucle vectorizado con código desenrollado para convertir cualquier elemento sobrante de la matriz.
Aquí está el bucle principal vectorizado, menos un código de inicialización constante, comprobación de errores y limpieza que solo se ejecutará una vez:
.L7:
vrndscaleps zmm0, ZMMWORD PTR [rdi+rax], 1
vcvttps2dq zmm0, zmm0
vmovdqu32 ZMMWORD PTR [rsi+rax], zmm0
add rax, 64
cmp rax, rcx
jne .L7
El águila observará dos diferencias con el código generado por el programa de Cássio Renan: utiliza% zmm en lugar de% ymm registros, y almacena los resultados con un
vmovdqu32
no
vmovdqu32
lugar de un
vmovdqa64
alineado.
Clang 8.0.0 con las mismas banderas hace diferentes elecciones sobre el desenrollado de los bucles. Cada iteración opera en ocho vectores de 512 bits (es decir, 128 flotadores de precisión simple), pero el código para recoger las sobras no se desenrolla. Si quedan al menos 64 flotadores después de eso, usa otras cuatro instrucciones AVX512 para esos, y luego limpia los extras con un bucle no vectorizado.
Si compila el programa original en Clang ++, lo aceptará sin quejarse, pero no realizará las mismas optimizaciones: aún no asumirá que la
length
sea un múltiplo del ancho del vector, ni que los punteros estén alineados.
Prefiere el código AVX512 a AVX256, incluso sin
-mprefer-vector-width=512
.
test rdx, rdx
jle .LBB0_14
cmp rdx, 63
ja .LBB0_6
xor eax, eax
jmp .LBB0_13
.LBB0_6:
mov rax, rdx
and rax, -64
lea r9, [rax - 64]
mov r10, r9
shr r10, 6
add r10, 1
mov r8d, r10d
and r8d, 1
test r9, r9
je .LBB0_7
mov ecx, 1
sub rcx, r10
lea r9, [r8 + rcx]
add r9, -1
xor ecx, ecx
.LBB0_9: # =>This Inner Loop Header: Depth=1
vrndscaleps zmm0, zmmword ptr [rdi + 4*rcx], 9
vrndscaleps zmm1, zmmword ptr [rdi + 4*rcx + 64], 9
vrndscaleps zmm2, zmmword ptr [rdi + 4*rcx + 128], 9
vrndscaleps zmm3, zmmword ptr [rdi + 4*rcx + 192], 9
vcvttps2dq zmm0, zmm0
vcvttps2dq zmm1, zmm1
vcvttps2dq zmm2, zmm2
vmovups zmmword ptr [rsi + 4*rcx], zmm0
vmovups zmmword ptr [rsi + 4*rcx + 64], zmm1
vmovups zmmword ptr [rsi + 4*rcx + 128], zmm2
vcvttps2dq zmm0, zmm3
vmovups zmmword ptr [rsi + 4*rcx + 192], zmm0
vrndscaleps zmm0, zmmword ptr [rdi + 4*rcx + 256], 9
vrndscaleps zmm1, zmmword ptr [rdi + 4*rcx + 320], 9
vrndscaleps zmm2, zmmword ptr [rdi + 4*rcx + 384], 9
vrndscaleps zmm3, zmmword ptr [rdi + 4*rcx + 448], 9
vcvttps2dq zmm0, zmm0
vcvttps2dq zmm1, zmm1
vcvttps2dq zmm2, zmm2
vcvttps2dq zmm3, zmm3
vmovups zmmword ptr [rsi + 4*rcx + 256], zmm0
vmovups zmmword ptr [rsi + 4*rcx + 320], zmm1
vmovups zmmword ptr [rsi + 4*rcx + 384], zmm2
vmovups zmmword ptr [rsi + 4*rcx + 448], zmm3
sub rcx, -128
add r9, 2
jne .LBB0_9
test r8, r8
je .LBB0_12
.LBB0_11:
vrndscaleps zmm0, zmmword ptr [rdi + 4*rcx], 9
vrndscaleps zmm1, zmmword ptr [rdi + 4*rcx + 64], 9
vrndscaleps zmm2, zmmword ptr [rdi + 4*rcx + 128], 9
vrndscaleps zmm3, zmmword ptr [rdi + 4*rcx + 192], 9
vcvttps2dq zmm0, zmm0
vcvttps2dq zmm1, zmm1
vcvttps2dq zmm2, zmm2
vcvttps2dq zmm3, zmm3
vmovups zmmword ptr [rsi + 4*rcx], zmm0
vmovups zmmword ptr [rsi + 4*rcx + 64], zmm1
vmovups zmmword ptr [rsi + 4*rcx + 128], zmm2
vmovups zmmword ptr [rsi + 4*rcx + 192], zmm3
.LBB0_12:
cmp rax, rdx
je .LBB0_14
.LBB0_13: # =>This Inner Loop Header: Depth=1
vmovss xmm0, dword ptr [rdi + 4*rax] # xmm0 = mem[0],zero,zero,zero
vroundss xmm0, xmm0, xmm0, 9
vcvttss2si ecx, xmm0
mov dword ptr [rsi + 4*rax], ecx
add rax, 1
cmp rdx, rax
jne .LBB0_13
.LBB0_14:
pop rax
vzeroupper
ret
.LBB0_7:
xor ecx, ecx
test r8, r8
jne .LBB0_11
jmp .LBB0_12
ICC 19 también genera instrucciones AVX512, pero muy diferentes de
clang
.
Realiza más configuraciones con constantes mágicas, pero no desenrolla ningún bucle, sino que opera en vectores de 512 bits.
Este código también funciona en otros compiladores y arquitecturas.
(Aunque MSVC solo admite ISA hasta AVX2 y no puede auto-vectorizar el bucle). En ARM con
-march=armv8-a+simd
, por ejemplo, genera un bucle vectorizado con
frintm v0.4s, v0.4s
y
fcvtzs v0.4s, v0.4s
.
Echa un vistazo a los números mágicos . El algoritmo propuesto en la página web debería ser mucho más eficiente que el simple casting. Nunca lo he usado, pero esta es la comparación de rendimiento que ofrecen en el sitio (xs_ToInt y xs_CRoundToInt son las funciones propuestas):
Performing 10000000 times:
simple cast 2819 ms i.e. i = (long)f;
xs_ToInt 1242 ms i.e. i = xs_ToInt(f); //numerically same as above
bit-twiddle(full) 1093 ms i.e. i = BitConvertToInt(f); //rounding from Fluid
fistp 676 ms i.e. i = FISTToInt(f); //Herf, et al x86 Assembly rounding
bit-twiddle(limited) 623 ms i.e. i = FloatTo23Bits(f); //Herf, rounding only in the range (0...1]
xs_CRoundToInt 609 ms i.e. i = xs_CRoundToInt(f); //rounding with "magic" numbers
Además, el xs_ToInt se modifica aparentemente para que el rendimiento mejore:
Performing 10000000 times:
simple cast convert 3186 ms i.e. fi = (f*65536);
fistp convert 3031 ms i.e. fi = FISTToInt(f*65536);
xs_ToFix 622 ms i.e. fi = xs_Fix<16>::ToFix(f);
Breve explicación de cómo funciona el método de los ''números mágicos'':
"Básicamente, para agregar dos números de punto flotante, su procesador" alinea "los puntos decimales de los números para que pueda agregar fácilmente los bits. Esto lo hace" normalizando "los números de modo que se conserven los bits más significativos. , es decir, el número más pequeño "se normaliza" para coincidir con el más grande. Por lo tanto, el principio de la conversión del "número mágico" que usa xs_CRoundToInt () es el siguiente: agregamos un número de punto flotante lo suficientemente grande (un número que es tan grande que hay los dígitos significativos solo HACIA ARRIBA hasta el punto decimal, y ninguno después de él) al que está convirtiendo, de manera que: (a) el número se normaliza por el procesador a su equivalente entero y (b) al agregar los dos no se borra la integral bits significativos en el número que estaba intentando convertir (es decir, XX00 + 00YY = XXYY) ".
La cita está tomada de la misma página web.
Si está haciendo esto por lotes, el compilador puede autovectorizarlo, si sabe lo que está haciendo. Por ejemplo, aquí hay una pequeña implementación que autovectoriza la conversión de flotantes a enteros, en GCC:
#include <cmath>
// Compile with -O3 and -march=native to see autovectorization
__attribute__((optimize("-fno-trapping-math")))
void testFunction(float* input, int* output, int length) {
// Assume the input and output are aligned on a 32-bit boundary.
// Of course, you have to ensure this when calling testFunction, or else
// you will have problems.
input = static_cast<float*>(__builtin_assume_aligned(input, 32));
output = static_cast<int*>(__builtin_assume_aligned(output, 32));
// Also assume the length is a multiple of 32.
if (length & 31) __builtin_unreachable();
// Do the conversion
for (int i = 0; i < length; ++i) {
output[i] = floor(input[i]);
}
}
Este es el ensamblado generado para x86-64 (con instrucciones AVX512):
testFunction(float*, int*, int):
test edx, edx
jle .L5
lea ecx, [rdx-1]
xor eax, eax
.L3:
# you can see here that the conversion was vectorized
# to a vrndscaleps (that will round the float appropriately)
# and a vcvttps2dq (thal will perform the conversion)
vrndscaleps ymm0, YMMWORD PTR [rdi+rax], 1
vcvttps2dq ymm0, ymm0
vmovdqa64 YMMWORD PTR [rsi+rax], ymm0
add rax, 32
cmp rax, rdx
jne .L3
vzeroupper
.L5:
ret
Si su objetivo no es compatible con AVX512, aún se autovectorizará utilizando las instrucciones SSE4.1, asumiendo que las tiene.
Esta es la salida con
-O3 -msse4.1
:
testFunction(float*, int*, int):
test edx, edx
jle .L1
shr edx, 2
xor eax, eax
sal rdx, 4
.L3:
roundps xmm0, XMMWORD PTR [rdi+rax], 1
cvttps2dq xmm0, xmm0
movaps XMMWORD PTR [rsi+rax], xmm0
add rax, 16
cmp rax, rdx
jne .L3
.L1:
ret