Rendimiento de los tipos integrados: char vs short vs int vs float vs double
Al ver la respuesta de Alexandre C en el otro tema, tengo curiosidad por saber si hay alguna diferencia de rendimiento con los tipos integrados:
char
vsshort
vs vsint
vsfloat
vsdouble
.
Por lo general, no consideramos esa diferencia de desempeño (si la hay) en nuestros proyectos de la vida real, pero me gustaría saber esto con fines educativos. Las preguntas generales que se pueden hacer son:
¿Existe alguna diferencia de rendimiento entre la aritmética integral y la aritmética de punto flotante?
¿Cual es mas rápido? ¿Cuál es la razón de ser más rápido? Por favor explica esto.
Flotante versus entero:
Históricamente, la aritmética de números enteros podía ser mucho más lenta. En las computadoras modernas, este ya no es el caso (es algo más lento en algunas plataformas, pero a menos que escriba un código perfecto y optimice para cada ciclo, la diferencia se verá hundida por las otras ineficiencias en su código).
En procesadores algo limitados, como los de los teléfonos móviles de gama alta, el punto flotante puede ser algo más lento que el entero, pero generalmente está dentro de un orden de magnitud (o mejor), siempre que haya hardware de punto flotante disponible. Vale la pena señalar que esta brecha se está cerrando bastante rápidamente a medida que los teléfonos celulares deben ejecutar cargas de trabajo informáticas cada vez más generales.
En procesadores muy limitados (teléfonos móviles baratos y su tostadora), generalmente no hay hardware de punto flotante, por lo que las operaciones de punto flotante deben emularse en el software. Esto es lento: un par de órdenes de magnitud más lento que la aritmética de enteros.
Sin embargo, como dije, la gente espera que sus teléfonos y otros dispositivos se comporten cada vez más como "computadoras reales", y los diseñadores de hardware están reforzando rápidamente las FPU para satisfacer esa demanda. A menos que esté persiguiendo hasta el último ciclo, o esté escribiendo código para CPU muy limitadas que tienen poco o ningún soporte de punto flotante, la distinción de rendimiento no le importa.
Tipos de enteros de diferentes tamaños:
Normalmente, las CPU son más rápidas a la hora de operar con números enteros de su tamaño de palabra nativo (con algunas advertencias sobre los sistemas de 64 bits). Las operaciones de 32 bits suelen ser más rápidas que las de 8 o 16 bits en las CPU modernas, pero esto varía bastante entre arquitecturas. Además, recuerda que no puedes considerar la velocidad de una CPU de forma aislada; es parte de un sistema complejo. Incluso si operar con números de 16 bits es 2 veces más lento que operar con números de 32 bits, puede incluir el doble de datos en la jerarquía de caché cuando los representa con números de 16 bits en lugar de 32 bits. Si eso marca la diferencia entre que todos sus datos provengan del caché en lugar de sufrir frecuentes errores de caché, entonces el acceso más rápido a la memoria prevalecerá sobre el funcionamiento más lento de la CPU.
Otras notas:
La vectorización inclina aún más la balanza a favor de tipos más estrechos ( float
y enteros de 8 y 16 bits): puede realizar más operaciones en un vector del mismo ancho. Sin embargo, un buen código vectorial es difícil de escribir, por lo que no se obtiene este beneficio sin mucho trabajo cuidadoso.
¿Por qué hay diferencias de rendimiento?
En realidad, sólo hay dos factores que afectan si una operación es rápida o no en una CPU: la complejidad del circuito de la operación y la demanda del usuario de que la operación sea rápida.
(Dentro de lo razonable) cualquier operación se puede realizar rápidamente, si los diseñadores de chips están dispuestos a arrojar suficientes transistores al problema. Pero los transistores cuestan dinero (o más bien, usar muchos transistores hace que el chip sea más grande, lo que significa que se obtienen menos chips por oblea y menores rendimientos, lo que cuesta dinero), por lo que los diseñadores de chips tienen que equilibrar cuánta complejidad usar para qué operaciones, y lo hacen en función de la demanda (percibida) de los usuarios. A grandes rasgos, se podría pensar en dividir las operaciones en cuatro categorías:
high demand low demand
high complexity FP add, multiply division
low complexity integer add popcount, hcf
boolean ops, shifts
Las operaciones de alta demanda y baja complejidad serán rápidas en casi cualquier CPU: son la fruta más fácil y confieren el máximo beneficio al usuario por transistor.
Las operaciones de alta demanda y complejidad serán rápidas en CPU costosas (como las que se usan en las computadoras), porque los usuarios están dispuestos a pagar por ellas. Sin embargo, probablemente no esté dispuesto a pagar $3 adicionales para que su tostadora tenga una multiplicación de FP rápida, por lo que las CPU baratas escatimarán en estas instrucciones.
las operaciones de baja demanda y alta complejidad generalmente serán lentas en casi todos los procesadores; simplemente no hay suficientes beneficios para justificar el costo.
Las operaciones de baja demanda y baja complejidad serán rápidas si alguien se molesta en pensar en ellas y, en caso contrario, inexistentes.
Otras lecturas:
- Agner Fog mantiene un sitio web agradable con mucha discusión sobre detalles de rendimiento de bajo nivel (y tiene una metodología de recopilación de datos muy científica para respaldarlo).
- El Manual de referencia de optimización de arquitecturas Intel® 64 e IA-32 (el enlace de descarga en PDF se encuentra en la parte inferior de la página) también cubre muchos de estos temas, aunque se centra en una familia específica de arquitecturas.
Absolutamente.
En primer lugar, por supuesto, depende completamente de la arquitectura de la CPU en cuestión.
Sin embargo, los tipos integral y de punto flotante se manejan de manera muy diferente, por lo que casi siempre ocurre lo siguiente:
- para operaciones simples, los tipos integrales son rápidos . Por ejemplo, la suma de enteros a menudo tiene una latencia de un solo ciclo, y la multiplicación de enteros suele ser de alrededor de 2 a 4 ciclos, IIRC.
- Los tipos de punto flotante solían funcionar mucho más lento. Sin embargo, en las CPU actuales, tienen un rendimiento excelente y cada unidad de punto flotante generalmente puede retirar una operación por ciclo, lo que genera el mismo (o similar) rendimiento que para las operaciones con números enteros. Sin embargo, la latencia suele ser peor. La suma de punto flotante a menudo tiene una latencia de alrededor de 4 ciclos (frente a 1 para los enteros).
- para algunas operaciones complejas, la situación es diferente, o incluso inversa. Por ejemplo, la división en FP puede tener menos latencia que para números enteros, simplemente porque la operación es compleja de implementar en ambos casos, pero es más útil en valores de FP, por lo que se puede dedicar más esfuerzo (y transistores) a optimizar ese caso.
En algunas CPU, los dobles pueden ser significativamente más lentos que los flotantes. En algunas arquitecturas, no hay hardware dedicado para dobles, por lo que se manejan pasando dos fragmentos de tamaño flotante, lo que proporciona un peor rendimiento y el doble de latencia. En otros (la FPU x86, por ejemplo), ambos tipos se convierten al mismo formato interno de coma flotante de 80 bits, en el caso de x86), por lo que el rendimiento es idéntico. En otros, tanto float como double tienen soporte de hardware adecuado, pero debido a que float tiene menos bits, se puede hacer un poco más rápido, generalmente reduciendo un poco la latencia en relación con las operaciones double.
Descargo de responsabilidad: todos los tiempos y características mencionados simplemente se extraen de la memoria. No busqué nada de eso, por lo que puede que esté incorrecto. ;)
Para diferentes tipos de enteros, la respuesta varía enormemente según la arquitectura de la CPU. La arquitectura x86, debido a su larga y complicada historia, tiene que soportar operaciones de 8, 16, 32 (y hoy en día 64) bits de forma nativa y, en general, todas son igualmente rápidas (utilizan básicamente el mismo hardware y solo cero las puntas superiores según sea necesario).
Sin embargo, en otras CPU, los tipos de datos más pequeños que un int
pueden ser más costosos de cargar/almacenar (es posible que sea necesario escribir un byte en la memoria cargando la palabra completa de 32 bits en la que se encuentra y luego aplicar un enmascaramiento de bits para actualizar el un solo byte en un registro y luego escribe la palabra completa). Del mismo modo, para tipos de datos mayores que int
, es posible que algunas CPU tengan que dividir la operación en dos, cargando/almacenando/calculando las mitades inferior y superior por separado.
Pero en x86, la respuesta es que en general no importa. Por razones históricas, se requiere que la CPU tenga un soporte bastante sólido para todos y cada uno de los tipos de datos. Entonces, la única diferencia que probablemente notarás es que las operaciones de punto flotante tienen más latencia (pero un rendimiento similar, por lo que no son más lentas per se, al menos si escribes tu código correctamente).
No creo que nadie haya mencionado las reglas de promoción de números enteros. En C/C++ estándar, no se puede realizar ninguna operación en un tipo menor que int
. Si char o short resultan ser más pequeños que int en la plataforma actual, se promocionan implícitamente a int (que es una fuente importante de errores). El cumplidor debe realizar esta promoción implícita; no hay forma de evitarlo sin violar el estándar.
Las promociones de números enteros significan que ninguna operación (suma, bit a bit, lógica, etc., etc.) en el lenguaje puede ocurrir en un tipo de entero más pequeño que int. Por lo tanto, las operaciones en char/short/int son generalmente igualmente rápidas, ya que las primeras se promueven a las segundas.
Y además de las promociones de números enteros, están las "conversiones aritméticas habituales", lo que significa que C se esfuerza por hacer que ambos operandos sean del mismo tipo, convirtiendo uno de ellos en el mayor de los dos, en caso de que sean diferentes.
Sin embargo, la CPU puede realizar varias operaciones de carga/almacenamiento en los niveles 8, 16, 32, etc. En arquitecturas de 8 y 16 bits, esto a menudo significa que los tipos de 8 y 16 bits son más rápidos a pesar de las promociones de números enteros. En una CPU de 32 bits, en realidad podría significar que los tipos más pequeños son más lentos , porque quiere tener todo perfectamente alineado en fragmentos de 32 bits. Los compiladores de 32 bits normalmente optimizan la velocidad y asignan tipos de enteros más pequeños en un espacio mayor que el especificado.
Aunque generalmente los tipos de enteros más pequeños, por supuesto, ocupan menos espacio que los más grandes, por lo que si desea optimizar el tamaño de la RAM, es preferible que los prefiera.
La primera respuesta anterior es excelente y copié un pequeño bloque en el siguiente duplicado (ya que aquí es donde terminé primero).
¿Son "char" y "small int" más lentos que "int"?
Me gustaría ofrecer el siguiente código que perfila la asignación, inicialización y realización de algunos cálculos aritméticos en los distintos tamaños de enteros:
#include <iostream>
#include <windows.h>
using std::cout; using std::cin; using std::endl;
LARGE_INTEGER StartingTime, EndingTime, ElapsedMicroseconds;
LARGE_INTEGER Frequency;
void inline showElapsed(const char activity [])
{
QueryPerformanceCounter(&EndingTime);
ElapsedMicroseconds.QuadPart = EndingTime.QuadPart - StartingTime.QuadPart;
ElapsedMicroseconds.QuadPart *= 1000000;
ElapsedMicroseconds.QuadPart /= Frequency.QuadPart;
cout << activity << " took: " << ElapsedMicroseconds.QuadPart << "us" << endl;
}
int main()
{
cout << "Hallo!" << endl << endl;
QueryPerformanceFrequency(&Frequency);
const int32_t count = 1100100;
char activity[200];
//-----------------------------------------------------------------------------------------//
sprintf_s(activity, "Initialise & Set %d 8 bit integers", count);
QueryPerformanceCounter(&StartingTime);
int8_t *data8 = new int8_t[count];
for (int i = 0; i < count; i++)
{
data8[i] = i;
}
showElapsed(activity);
sprintf_s(activity, "Add 5 to %d 8 bit integers", count);
QueryPerformanceCounter(&StartingTime);
for (int i = 0; i < count; i++)
{
data8[i] = i + 5;
}
showElapsed(activity);
cout << endl;
//-----------------------------------------------------------------------------------------//
//-----------------------------------------------------------------------------------------//
sprintf_s(activity, "Initialise & Set %d 16 bit integers", count);
QueryPerformanceCounter(&StartingTime);
int16_t *data16 = new int16_t[count];
for (int i = 0; i < count; i++)
{
data16[i] = i;
}
showElapsed(activity);
sprintf_s(activity, "Add 5 to %d 16 bit integers", count);
QueryPerformanceCounter(&StartingTime);
for (int i = 0; i < count; i++)
{
data16[i] = i + 5;
}
showElapsed(activity);
cout << endl;
//-----------------------------------------------------------------------------------------//
//-----------------------------------------------------------------------------------------//
sprintf_s(activity, "Initialise & Set %d 32 bit integers", count);
QueryPerformanceCounter(&StartingTime);
int32_t *data32 = new int32_t[count];
for (int i = 0; i < count; i++)
{
data32[i] = i;
}
showElapsed(activity);
sprintf_s(activity, "Add 5 to %d 32 bit integers", count);
QueryPerformanceCounter(&StartingTime);
for (int i = 0; i < count; i++)
{
data32[i] = i + 5;
}
showElapsed(activity);
cout << endl;
//-----------------------------------------------------------------------------------------//
//-----------------------------------------------------------------------------------------//
sprintf_s(activity, "Initialise & Set %d 64 bit integers", count);
QueryPerformanceCounter(&StartingTime);
int64_t *data64 = new int64_t[count];
for (int i = 0; i < count; i++)
{
data64[i] = i;
}
showElapsed(activity);
sprintf_s(activity, "Add 5 to %d 64 bit integers", count);
QueryPerformanceCounter(&StartingTime);
for (int i = 0; i < count; i++)
{
data64[i] = i + 5;
}
showElapsed(activity);
cout << endl;
//-----------------------------------------------------------------------------------------//
getchar();
}
/*
My results on i7 4790k:
Initialise & Set 1100100 8 bit integers took: 444us
Add 5 to 1100100 8 bit integers took: 358us
Initialise & Set 1100100 16 bit integers took: 666us
Add 5 to 1100100 16 bit integers took: 359us
Initialise & Set 1100100 32 bit integers took: 870us
Add 5 to 1100100 32 bit integers took: 276us
Initialise & Set 1100100 64 bit integers took: 2201us
Add 5 to 1100100 64 bit integers took: 659us
*/
Mis resultados en MSVC en i7 4790k:
Inicializar y configurar 1100100 enteros de 8 bits tomados: 444us
Agregar 5 a 1100100 enteros de 8 bits tomados: 358us
Inicializar y configurar 1100100 enteros de 16 bits tomados: 666us
Agregar 5 a 1100100 enteros de 16 bits tomados: 359us
Inicializar y configurar 1100100 enteros de 32 bits tomados: 870us
Agregar 5 a 1100100 enteros de 32 bits tomados: 276us
Inicializar y configurar 1100100 enteros de 64 bits tomados: 2201us
Agregar 5 a 1100100 enteros de 64 bits tomados: 659us