c - guia - qgis manual
¿Cuándo es el montaje más rápido que C? (30)
¿Respuesta corta? Algunas veces.
Técnicamente, cada abstracción tiene un costo y un lenguaje de programación es una abstracción de cómo funciona la CPU. C sin embargo está muy cerca. Hace años, recuerdo haber reído en voz alta cuando inicié sesión en mi cuenta de UNIX y recibí el siguiente mensaje de fortuna (cuando esas cosas eran populares):
El lenguaje de programación C: un lenguaje que combina la flexibilidad del lenguaje ensamblador con el poder del lenguaje ensamblador.
Es gracioso porque es verdad: C es como un lenguaje de ensamblaje portátil.
Vale la pena notar que el lenguaje ensamblador simplemente se ejecuta como lo escribes. Sin embargo, hay un compilador entre C y el lenguaje ensamblador que genera, y eso es extremadamente importante porque la rapidez de su código C tiene mucho que ver con lo bueno que es su compilador.
Cuando gcc apareció en escena, una de las cosas que lo hizo tan popular fue que a menudo era mucho mejor que los compiladores de C que venían con muchos sabores UNIX comerciales. No solo era ANSI C (ninguna de estas basuras de K&R C), era más robusta y, por lo general, producía un código mejor (más rápido). No siempre pero a menudo.
Les cuento todo esto porque no hay una regla general sobre la velocidad de C y el ensamblador porque no existe un estándar objetivo para C.
Del mismo modo, el ensamblador varía mucho según el procesador que esté ejecutando, las especificaciones de su sistema, el conjunto de instrucciones que esté utilizando, etc. Históricamente ha habido dos familias de arquitectura de CPU: CISC y RISC. El jugador más importante en CISC fue y sigue siendo la arquitectura Intel x86 (y el conjunto de instrucciones). RISC dominó el mundo UNIX (MIPS6000, Alpha, Sparc, etc.). CISC ganó la batalla por los corazones y las mentes.
De todos modos, la sabiduría popular cuando era un desarrollador más joven era que el x86 escrito a mano a menudo podía ser mucho más rápido que C porque la forma en que funcionaba la arquitectura tenía una complejidad que se beneficiaba de un humano que lo hacía. Por otro lado, RISC parecía estar diseñado para compiladores, por lo que nadie (lo sabía) escribió, dijo Sparc, el ensamblador. Estoy seguro de que esas personas existieron, pero sin duda ambas se han vuelto locas y han sido institucionalizadas hasta ahora.
Los conjuntos de instrucciones son un punto importante incluso en la misma familia de procesadores. Ciertos procesadores Intel tienen extensiones como SSE a través de SSE4. AMD tenía sus propias instrucciones SIMD. El beneficio de un lenguaje de programación como C era que alguien podía escribir su biblioteca, por lo que estaba optimizado para cualquier procesador en el que estuvieras ejecutando. Ese fue un trabajo duro en ensamblador.
Todavía hay optimizaciones que puede hacer en el ensamblador que ningún compilador podría hacer y un ensamblador bien escrito algo será tan rápido o más rápido que su equivalente en C. La pregunta más grande es: ¿vale la pena?
En última instancia, aunque el ensamblador era un producto de su época y era más popular en un momento en que los ciclos de la CPU eran caros. Hoy en día, una CPU que cuesta $ 5-10 para fabricar (Intel Atom) puede hacer casi cualquier cosa que cualquiera pueda desear. La única razón real para escribir ensamblador en estos días es para cosas de bajo nivel como algunas partes de un sistema operativo (aún así, la gran mayoría del kernel de Linux está escrito en C), controladores de dispositivos, posiblemente dispositivos integrados (aunque C tiende a dominar allí). también) y así sucesivamente. O solo por patadas (que es algo masoquista).
Una de las razones indicadas para conocer al ensamblador es que, en ocasiones, puede emplearse para escribir código que sea más eficaz que escribir ese código en un lenguaje de nivel superior, en particular C. Sin embargo, también he oído decir muchas veces que, aunque eso no es del todo falso, los casos en los que el ensamblador puede usarse para generar un código más eficaz son extremadamente raros y requieren un conocimiento experto y experiencia con el ensamblaje.
Esta pregunta ni siquiera se refiere al hecho de que las instrucciones del ensamblador serán específicas de la máquina y no portátiles, o cualquiera de los otros aspectos del ensamblador. Hay muchas buenas razones para conocer ensamblador además de esta, por supuesto, pero se trata de una pregunta específica que solicita ejemplos y datos, no un discurso extendido sobre lenguajes de ensamblador en comparación con lenguajes de nivel superior.
¿Puede alguien proporcionar algunos ejemplos específicos de casos en que el ensamblaje sea más rápido que el código C bien escrito con un compilador moderno, y puede respaldar esa afirmación con evidencia de perfiles? Estoy bastante seguro de que estos casos existen, pero realmente quiero saber exactamente qué tan esotéricos son estos casos, ya que parece ser un punto de cierta controversia.
Aunque C está "cerca" de la manipulación de bajo nivel de datos de 8 bits, 16 bits, 32 bits, 64 bits, hay algunas operaciones matemáticas no compatibles con C que a menudo se pueden realizar con elegancia en ciertas instrucciones de ensamblaje conjuntos:
Multiplicación de punto fijo: el producto de dos números de 16 bits es un número de 32 bits. Pero las reglas en C dicen que el producto de dos números de 16 bits es un número de 16 bits, y el producto de dos números de 32 bits es un número de 32 bits, la mitad inferior en ambos casos. Si quieres la mitad superior de una multiplicación de 16x16 o una multiplicación de 32x32, tienes que jugar juegos con el compilador. El método general es convertir a un ancho de bit más grande de lo necesario, multiplicar, desplazar hacia abajo y revertir:
int16_t x, y; // int16_t is a typedef for "short" // set x and y to something int16_t prod = (int16_t)(((int32_t)x*y)>>16);`
En este caso, el compilador puede ser lo suficientemente inteligente como para saber que realmente está tratando de obtener la mitad superior de una multiplicación de 16x16 y hacer lo correcto con la combinación de 16x16 nativa de la máquina. O puede ser estúpido y requerir una llamada de la biblioteca para realizar la multiplicación de 32x32 que es demasiado porque solo necesitas 16 bits del producto, pero el estándar C no te da ninguna forma de expresarte.
Ciertas operaciones de cambio de bits (rotación / acarreo):
// 256-bit array shifted right in its entirety: uint8_t x[32]; for (int i = 32; --i > 0; ) { x[i] = (x[i] >> 1) | (x[i-1] << 7); } x[0] >>= 1;
Esto no es demasiado poco elegante en C, pero nuevamente, a menos que el compilador sea lo suficientemente inteligente como para darse cuenta de lo que está haciendo, va a hacer un montón de trabajo "innecesario". Muchos conjuntos de instrucciones de ensamblaje le permiten girar o desplazarse hacia la izquierda / derecha con el resultado en el registro de acarreo, por lo que podría cumplir con las instrucciones anteriores en 34: cargar un puntero al principio de la matriz, borrar el acarreo y realizar 32 8- desplazamiento a la derecha del bit, utilizando autoincremento en el puntero.
Para otro ejemplo, hay registros de desplazamiento de realimentación lineal (LFSR) que se realizan con elegancia en el ensamblaje: tome una porción de N bits (8, 16, 32, 64, 128, etc.), cambie todo a la derecha en 1 (vea más arriba) algoritmo), si el acarreo resultante es 1, entonces XOR en un patrón de bits que representa el polinomio.
Dicho esto, no recurriría a estas técnicas a menos que tuviera serias limitaciones de rendimiento. Como han dicho otros, el ensamblaje es mucho más difícil de documentar / depurar / probar / mantener que el código C: la ganancia de rendimiento conlleva algunos costos serios.
edit: 3. La detección de desbordamiento es posible en el ensamblaje (no se puede hacer realmente en C), esto hace que algunos algoritmos sean mucho más fáciles.
Casi siempre que el compilador ve un código de punto flotante, una versión escrita a mano será más rápida. La razón principal es que el compilador no puede realizar optimizaciones robustas. Vea este artículo de MSDN para una discusión sobre el tema. Aquí hay un ejemplo donde la versión de ensamblaje es el doble de la velocidad que la versión C (compilada con VS2K5):
#include "stdafx.h"
#include <windows.h>
float KahanSum
(
const float *data,
int n
)
{
float
sum = 0.0f,
C = 0.0f,
Y,
T;
for (int i = 0 ; i < n ; ++i)
{
Y = *data++ - C;
T = sum + Y;
C = T - sum - Y;
sum = T;
}
return sum;
}
float AsmSum
(
const float *data,
int n
)
{
float
result = 0.0f;
_asm
{
mov esi,data
mov ecx,n
fldz
fldz
l1:
fsubr [esi]
add esi,4
fld st(0)
fadd st(0),st(2)
fld st(0)
fsub st(0),st(3)
fsub st(0),st(2)
fstp st(2)
fstp st(2)
loop l1
fstp result
fstp result
}
return result;
}
int main (int, char **)
{
int
count = 1000000;
float
*source = new float [count];
for (int i = 0 ; i < count ; ++i)
{
source [i] = static_cast <float> (rand ()) / static_cast <float> (RAND_MAX);
}
LARGE_INTEGER
start,
mid,
end;
float
sum1 = 0.0f,
sum2 = 0.0f;
QueryPerformanceCounter (&start);
sum1 = KahanSum (source, count);
QueryPerformanceCounter (&mid);
sum2 = AsmSum (source, count);
QueryPerformanceCounter (&end);
cout << " C code: " << sum1 << " in " << (mid.QuadPart - start.QuadPart) << endl;
cout << "asm code: " << sum2 << " in " << (end.QuadPart - mid.QuadPart) << endl;
return 0;
}
Y algunos números de mi PC ejecutan una versión de lanzamiento predeterminada * :
C code: 500137 in 103884668
asm code: 500137 in 52129147
Por interés, cambié el bucle con un dec / jnz y no hizo ninguna diferencia en los tiempos, a veces más rápido, a veces más lento. Supongo que el aspecto limitado de la memoria enana otras optimizaciones.
Vaya, estaba ejecutando una versión ligeramente diferente del código y dio salida a los números al revés (es decir, ¡C era más rápido!). Reparado y actualizado los resultados.
En mi trabajo, hay tres razones para que yo sepa y use el ensamblaje. En orden de importancia:
Depuración: a menudo obtengo un código de biblioteca que tiene errores o documentación incompleta. Entiendo lo que está haciendo al intervenir en el nivel de ensamblaje. Tengo que hacer esto una vez a la semana. También lo uso como una herramienta para depurar problemas en los que mis ojos no detectan el error idiomático en C / C ++ / C #. Mirando a la asamblea se pasa eso.
Optimización: el compilador funciona bastante bien en la optimización, pero juego en un campo de juego diferente al de la mayoría. Escribo un código de procesamiento de imágenes que generalmente comienza con un código que se ve así:
for (int y=0; y < imageHeight; y++) { for (int x=0; x < imageWidth; x++) { // do something } }
el "hacer algo parte" normalmente ocurre en el orden de varios millones de veces (es decir, entre 3 y 30). Al raspar los ciclos en esa fase de "hacer algo", las ganancias de rendimiento se magnifican enormemente. Normalmente no comienzo allí. Generalmente comienzo escribiendo el código para que funcione primero, luego hago lo mejor para refactorizar la C para que sea naturalmente mejor (mejor algoritmo, menos carga en el bucle, etc.). Por lo general, necesito leer el ensamblaje para ver qué sucede y rara vez necesito escribirlo. Hago esto tal vez cada dos o tres meses.
haciendo algo que el idioma no me deja. Estos incluyen: obtener la arquitectura del procesador y las características específicas del procesador, acceder a los indicadores que no están en la CPU (hombre, realmente deseo que C le haya dado acceso al indicador de control), etc. Lo hago tal vez una vez al año o dos años.
Este es un ejemplo del mundo real: el punto fijo se multiplica en compiladores antiguos.
Estos no solo son útiles en dispositivos sin punto flotante, sino que brillan cuando se trata de precisión, ya que le brindan 32 bits de precisión con un error predecible (el flotador solo tiene 23 bits y es más difícil predecir la pérdida de precisión). es decir, precisión absoluta uniforme en todo el rango, en lugar de precisión relativa cercana a uniforme ( float
).
Los compiladores modernos optimizan muy bien este ejemplo de punto fijo, por lo que para ejemplos más modernos que todavía necesitan un código específico del compilador, consulte
- Obtención de la parte alta de la multiplicación de enteros de 64 bits : una versión portátil que utiliza
uint64_t
parauint64_t
de 32x32 => 64 bits no se puede optimizar en una CPU de 64 bits, por lo que necesita intrínsecos o__int128
para un código eficiente en sistemas de 64 bits. - _umul128 en Windows de 32 bits : MSVC no siempre hace un buen trabajo al multiplicar enteros de 32 bits convertidos en 64, por lo que los intrínsecos ayudaron mucho.
C no tiene un operador de multiplicación completa (resultado de 2N bits de las entradas de N bits). La forma habitual de expresarlo en C es convertir las entradas al tipo más amplio y esperar que el compilador reconozca que los bits superiores de las entradas no son interesantes:
// on a 32-bit machine, int can hold 32-bit fixed-point integers.
int inline FixedPointMul (int a, int b)
{
long long a_long = a; // cast to 64 bit.
long long product = a_long * b; // perform multiplication
return (int) (product >> 16); // shift by the fixed point bias
}
El problema con este código es que hacemos algo que no se puede expresar directamente en el lenguaje C. Queremos multiplicar dos números de 32 bits y obtener un resultado de 64 bits, de los cuales devolvemos los 32 bits medios. Sin embargo, en C esta multiplicación no existe. Todo lo que puedes hacer es promover los enteros a 64 bits y hacer una multiplicación de 64 * 64 = 64.
Sin embargo, x86 (y ARM, MIPS y otros) pueden hacer la multiplicación en una sola instrucción. Algunos compiladores solían ignorar este hecho y generar código que llama a una función de biblioteca de tiempo de ejecución para realizar la multiplicación. El cambio en 16 también se realiza a menudo mediante una rutina de biblioteca (también el x86 puede hacer esos cambios).
Así que nos quedamos con una o dos llamadas a la biblioteca solo para multiplicar. Esto tiene graves consecuencias. El cambio no solo es más lento, sino que los registros deben conservarse en todas las llamadas de función, y tampoco ayuda a alinear y desenrollar el código.
Si reescribe el mismo código en el ensamblador (en línea) puede obtener un aumento de velocidad significativo.
Además de esto: usar ASM no es la mejor manera de resolver el problema. La mayoría de los compiladores le permiten usar algunas instrucciones de ensamblador en forma intrínseca si no puede expresarlas en C. El compilador VS.NET2008 por ejemplo expone el mul de 32 * 32 = 64 bits como __emul y el cambio de 64 bits como __ll_rshift.
Usando intrínsecos, puede reescribir la función de manera que el compilador C tenga la oportunidad de entender lo que está sucediendo. Esto permite que el código esté en línea, que se asigne al registro, que también se pueda hacer la eliminación de subexpresiones comunes y la propagación constante. Obtendrás una gran mejora de rendimiento sobre el código del ensamblador escrito a mano de esa manera.
Para referencia: el resultado final de la mul de punto fijo para el compilador VS.NET es:
int inline FixedPointMul (int a, int b)
{
return (int) __ll_rshift(__emul(a,b),16);
}
La diferencia de rendimiento de las divisiones de puntos fijos es aún mayor. Tuve mejoras hasta el factor 10 para el código de punto fijo de división pesada al escribir un par de líneas ASM.
El uso de Visual C ++ 2013 proporciona el mismo código de ensamblaje para ambas formas.
gcc4.1 de 2007 también optimiza la versión C pura muy bien. (El explorador del compilador de Godbolt no tiene ninguna versión anterior de gcc instalada, pero probablemente incluso las versiones más antiguas de GCC podrían hacer esto sin intrínsecos).
Consulte source + asm para x86 (32 bits) y ARM en el explorador del compilador Godbolt . (Desafortunadamente, no tiene ningún compilador lo suficientemente antiguo como para producir código incorrecto de la versión simple de C pura.)
Las CPU modernas pueden hacer cosas para las que C no tiene operadores, como popcnt
o bit-scan para encontrar el primer o último bit establecido . (POSIX tiene una función ffs()
, pero su semántica no coincide con x86 bsf
/ bsr
. Consulte https://en.wikipedia.org/wiki/Find_first_set ).
Algunos compiladores a veces pueden reconocer un bucle que cuenta la cantidad de bits establecidos en un entero y compilarlo en una instrucción popcnt
(si está habilitada en tiempo de compilación), pero es mucho más confiable usar __builtin_popcnt
en GNU C, o en x86 si Re solo se dirige al hardware con SSE4.2: _mm_popcnt_u32
desde <immintrin.h>
.
O en C ++, asigne a un std::bitset<32>
y use .count()
. (Este es un caso en el que el lenguaje ha encontrado una manera de exponer de manera portátil una implementación optimizada de popcount a través de la biblioteca estándar, de una manera que siempre compilará algo correcto y puede aprovechar lo que sea compatible con el objetivo). Consulte también https://en.wikipedia.org/wiki/Hamming_weight#Language_support .
De manera similar, ntohl
puede compilar en bswap
(intercambio de byte x86 de 32 bits para conversión endian) en algunas implementaciones de C que lo tienen.
Otra área importante para intrínsecos o asm escritos a mano es la vectorización manual con instrucciones SIMD. Los compiladores no son malos con bucles simples como dst[i] += src[i] * 10.0;
, pero a menudo lo hacen mal o no se auto-vectorizan en absoluto cuando las cosas se complican más. Por ejemplo, es poco probable que obtenga algo como ¿Cómo implementar atoi usando SIMD? generado automáticamente por el compilador a partir de código escalar.
Hace muchos años, estaba enseñando a alguien a programar en C. El ejercicio consistía en rotar un gráfico 90 grados. Regresó con una solución que tardó varios minutos en completarse, principalmente porque estaba usando multiplicaciones y divisiones, etc.
Le mostré cómo replantear el problema usando cambios de bits, y el tiempo para procesar se redujo a unos 30 segundos en el compilador que no estaba optimizando.
Acabo de recibir un compilador de optimización y el mismo código rotó el gráfico en <5 segundos. Miré el código de ensamblaje que estaba generando el compilador, y por lo que vi, decidí allí y entonces que mis días de escribir ensambladores habían terminado.
Punto uno que no es la respuesta.
Incluso si nunca programa en él, me resulta útil conocer al menos un conjunto de instrucciones de ensamblador. Esto es parte de la búsqueda interminable de los programadores para saber más y, por lo tanto, ser mejores. También es útil al ingresar a marcos en los que no tiene el código fuente y al menos tiene una idea aproximada de lo que está sucediendo. También te ayuda a entender JavaByteCode y .Net IL ya que ambos son similares a ensamblador.
Para responder a la pregunta cuando tenga una pequeña cantidad de código o una gran cantidad de tiempo. Más útil para usar en chips integrados, donde la baja complejidad de los chips y la poca competencia en los compiladores que apuntan a estos chips pueden inclinar la balanza a favor de los humanos. También para los dispositivos restringidos, a menudo está cambiando el tamaño del código / tamaño de la memoria / rendimiento de una manera que sería difícil instruir a un compilador. por ejemplo, sé que esta acción del usuario no se llama a menudo, por lo que tendré un tamaño de código pequeño y un rendimiento pobre, pero esta otra función que se ve similar se usa cada segundo, así que tendré un tamaño de código más grande y un rendimiento más rápido. Ese es el tipo de intercambio que un programador de ensamblaje experto puede usar.
También me gustaría agregar que hay una gran cantidad de puntos intermedios donde puede codificar en C compilar y examinar el Ensamblaje producido, luego cambiar su código C o modificarlo y mantenerlo como ensamblaje.
Mi amigo trabaja en microcontroladores, actualmente chips para controlar pequeños motores eléctricos. Trabaja en una combinación de bajo nivel c y montaje. Una vez me habló de un buen día de trabajo en el que redujo el bucle principal de 48 instrucciones a 43. También se enfrenta a opciones como que el código ha crecido para llenar el chip de 256k y el negocio está queriendo una nueva característica, ¿verdad?
- Eliminar una característica existente
- Reduzca el tamaño de algunas o todas las funciones existentes, tal vez a costa del rendimiento.
- Defienda el cambio a un chip más grande con un mayor costo, mayor consumo de energía y mayor factor de forma.
Me gustaría agregar como desarrollador comercial con una cartera o lenguajes, plataformas, tipos de aplicaciones que nunca he sentido la necesidad de sumergirme en la escritura de ensamblajes. Siempre he apreciado el conocimiento que obtuve al respecto. Y a veces se depura en ella.
Sé que he respondido mucho más a la pregunta "por qué debería aprender el ensamblador", pero creo que es una pregunta más importante que cuando es más rápido.
Así que volvamos a intentarlo. Deberías estar pensando en el montaje.
- trabajando en la función del sistema operativo de bajo nivel
- Trabajando en un compilador.
- Trabajando en un chip extremadamente limitado, sistema embebido, etc.
Recuerde comparar su ensamblaje con el compilador generado para ver cuál es más rápido / más pequeño / mejor.
David
Sin dar ningún ejemplo específico o evidencia del generador de perfiles, puede escribir un mejor ensamblador que el compilador cuando sepa más que el compilador.
En el caso general, un compilador de C moderno sabe mucho más sobre cómo optimizar el código en cuestión: sabe cómo funciona la canalización del procesador, puede tratar de reordenar las instrucciones más rápido que un humano, y así sucesivamente, es básicamente lo mismo que una computadora tan buena o mejor que el mejor jugador humano para juegos de mesa, etc. simplemente porque puede hacer búsquedas dentro del espacio problemático más rápido que la mayoría de los humanos. Aunque teóricamente puedes desempeñarte tan bien como la computadora en un caso específico, ciertamente no puedes hacerlo a la misma velocidad, por lo que no es factible en más de unos pocos casos (es decir, el compilador lo superará si intentas escribir). más de unas pocas rutinas en ensamblador).
Por otro lado, hay casos en los que el compilador no tiene tanta información; yo diría que principalmente cuando se trabaja con diferentes formas de hardware externo, de las cuales el compilador no tiene conocimiento. El ejemplo principal probablemente son los controladores de dispositivo, donde el ensamblador combinado con el conocimiento íntimo del hardware en cuestión puede dar mejores resultados que un compilador de C.
Otros han mencionado instrucciones de propósito especial, que es lo que estoy hablando en el párrafo anterior: instrucciones que el compilador podría tener un conocimiento limitado o nulo, lo que hace posible que un humano escriba un código más rápido.
Solo cuando se usan conjuntos de instrucciones de propósito especial, el compilador no es compatible.
Para maximizar la potencia de cálculo de una CPU moderna con múltiples tuberías y ramificaciones predictivas, necesita estructurar el programa de ensamblaje de manera que sea a) casi imposible para un humano escribir b) aún más imposible de mantener.
Además, los mejores algoritmos, estructuras de datos y administración de memoria le brindarán al menos un orden de magnitud más de rendimiento que las micro optimizaciones que puede realizar en el ensamblaje.
Un caso de uso que quizás ya no se aplique, pero para tu placer nerd: en la Amiga, la CPU y los chips de gráficos / audio lucharán por acceder a cierta área de RAM (los primeros 2MB de RAM son específicos). Por lo tanto, cuando solo tenía 2 MB de RAM (o menos), mostrar gráficos complejos y reproducir sonido acabaría con el rendimiento de la CPU.
En ensamblador, podría intercalar su código de una manera tan inteligente que la CPU solo intentaría acceder a la RAM cuando los gráficos / chips de audio estaban ocupados internamente (es decir, cuando el bus estaba libre). Por lo tanto, al reordenar sus instrucciones, el uso inteligente de la memoria caché de la CPU, la sincronización del bus, podría lograr algunos efectos que simplemente no serían posibles usando un lenguaje de nivel superior porque tenía que sincronizar cada comando, incluso insertar NOP aquí y allá para mantener los diversos chips de cada otro radar.
Esta es otra razón por la que la instrucción NOP (Sin Operación - No hacer nada) de la CPU puede hacer que toda la aplicación se ejecute más rápido.
[EDITAR] Por supuesto, la técnica depende de una configuración de hardware específica. ¿Cuál fue la razón principal por la que muchos juegos de Amiga no pudieron hacer frente a las CPU más rápidas? El tiempo de las instrucciones estaba apagado.
http://cr.yp.to/qhasm.html tiene muchos ejemplos.
LInux Assembly Howto , hace esta pregunta y ofrece los pros y los contras de usar Assembly.
Algunos ejemplos de mi experiencia:
Acceso a instrucciones a las que no se puede acceder desde C. Por ejemplo, muchas arquitecturas (como x86-64, IA-64, DEC Alpha y MIPS de 64 bits o PowerPC) admiten una multiplicación de 64 bits por 64 bits que produce un resultado de 128 bits. GCC recientemente agregó una extensión que brinda acceso a dichas instrucciones, pero antes de que se requiriera ese ensamblaje. Y el acceso a esta instrucción puede marcar una gran diferencia en las CPU de 64 bits cuando se implementa algo como RSA, a veces tanto como un factor de mejora de 4 en el rendimiento.
Acceso a banderas específicas de la CPU. El que más me ha mordido es la bandera de acarreo; al realizar una adición de precisión múltiple, si no tiene acceso al bit de transporte de la CPU, debe comparar el resultado para ver si se ha desbordado, lo que requiere de 3-5 instrucciones más por extremidad; y peor aún, que son bastante seriales en términos de acceso a datos, lo que mata el rendimiento en los modernos procesadores superescalares. Cuando se procesan miles de tales enteros en una fila, poder usar addc es una gran ganancia (también hay problemas superescalares con la contención en el bit de acarreo, pero las CPU modernas lo manejan bastante bien).
SIMD. Incluso los compiladores de autovectorización solo pueden hacer casos relativamente simples, por lo que si desea un buen rendimiento SIMD, desafortunadamente a menudo es necesario escribir el código directamente. Por supuesto, puede usar intrínsecos en lugar de ensamblajes, pero una vez que esté en el nivel intrínseco, básicamente está escribiendo ensamblados de todos modos, simplemente utilizando el compilador como asignador de registros y programador de instrucciones (nominalmente). (Tiendo a usar intrínsecos para SIMD simplemente porque el compilador puede generar los prólogos de funciones y otras cosas para mí, así que puedo usar el mismo código en Linux, OS X y Windows sin tener que lidiar con problemas ABI como las convenciones de llamadas de funciones, pero otros más que eso, los intrínsecos de la ESS realmente no son muy buenos (los de Altivec parecen mejores, aunque no tengo mucha experiencia con ellos).Como ejemplos de cosas que un compilador de vectorización (día actual) no puede resolver, lea sobreCorrección de errores AES o SIMD de rebanado de bits : se podría imaginar un compilador que podría analizar algoritmos y generar dicho código, pero me parece que un compilador tan inteligente está al menos a 30 años de existir (en el mejor de los casos).
Por otro lado, las máquinas multinúcleo y los sistemas distribuidos han cambiado muchas de las mayores victorias de rendimiento en la otra dirección: obtenga una aceleración adicional del 20% al escribir sus bucles internos en el ensamblaje, o el 300% ejecutándolos en varios núcleos, o el 10000% mediante ejecutándolos a través de un grupo de máquinas. Y, por supuesto, las optimizaciones de alto nivel (cosas como futuros, memoización, etc.) a menudo son mucho más fáciles de hacer en un lenguaje de nivel superior como ML o Scala que C o asm, y a menudo pueden proporcionar una ganancia de rendimiento mucho mayor. Entonces, como siempre, hay que hacer concesiones.
Creo que el caso general cuando el ensamblador es más rápido es cuando un programador de ensamblajes inteligente mira la salida del compilador y dice "este es un camino crítico para el rendimiento y puedo escribir esto para que sea más eficiente" y luego esa persona ajusta ese ensamblador o lo reescribe desde cero
Las operaciones de matriz que utilizan instrucciones SIMD son probablemente más rápidas que el código generado por el compilador.
Más a menudo de lo que piensas, C necesita hacer cosas que parecen ser innecesarias desde el punto de vista de un programador de ensambladores solo porque los estándares de C lo dicen.
Promoción de enteros, por ejemplo. Si desea cambiar una variable char en C, normalmente se esperaría que el código hiciera eso, solo un cambio de bit.
Sin embargo, los estándares obligan al compilador a hacer una extensión de signo a int antes del cambio y truncar el resultado a char después, lo que podría complicar el código según la arquitectura del procesador de destino.
Tengo una operación de transposición de bits que debe realizarse, en 192 o 256 bits en cada interrupción, que ocurre cada 50 microsegundos.
Sucede por un mapa fijo (restricciones de hardware). Usando C, tomó alrededor de 10 microsegundos para hacer. Cuando traduje esto a Assembler, teniendo en cuenta las características específicas de este mapa, el almacenamiento en caché de registros específicos y el uso de operaciones orientadas a bits; Tomó menos de 3.5 microsegundos para realizar.
Todo depende de su carga de trabajo.
Para las operaciones diarias, C y C ++ están bien, pero hay ciertas cargas de trabajo (cualquier transformación que involucre video (compresión, descompresión, efectos de imagen, etc.)) que requieren bastante ensamblaje para ser eficaces.
Por lo general, también implican el uso de extensiones de conjuntos de chips específicos de la CPU (MME / MMX / SSE / lo que sea) que se ajustan para ese tipo de operación.
Una de las posibilidades de la versión CP / M-86 de PolyPascal (hermana de Turbo Pascal) fue reemplazar la instalación de "uso de bios a salida de caracteres a la pantalla" con una rutina de lenguaje de máquina que en esencia Se le dio la x, y y, y la cadena para poner allí.
Esto permitió actualizar la pantalla mucho, mucho más rápido que antes!
Había espacio en el binario para incrustar el código de la máquina (unos pocos cientos de bytes) y también había otras cosas allí, por lo que era esencial exprimir todo lo posible.
Resulta que, como la pantalla era 80x25, ambas coordenadas podrían caber en un byte cada una, por lo que ambas podrían caber en una palabra de dos bytes. Esto permitió hacer los cálculos necesarios en menos bytes, ya que un solo agregado podría manipular ambos valores simultáneamente.
Por lo que sé, no hay compiladores de C que puedan combinar varios valores en un registro, siga las instrucciones de SIMD y divídalos más tarde (y no creo que las instrucciones de la máquina sean más cortas).
¿Qué tal crear código de máquina en tiempo de ejecución?
Mi hermano una vez (alrededor del año 2000) realizó un trazador de rayos en tiempo real extremadamente rápido al generar código en tiempo de ejecución. No puedo recordar los detalles, pero había algún tipo de módulo principal que recorría los objetos, luego preparaba y ejecutaba un código de máquina que era específico para cada objeto.
Sin embargo, con el tiempo, este método fue superado por un nuevo hardware de gráficos y se volvió inútil.
Hoy, creo que posiblemente se puedan optimizar algunas operaciones en big data (millones de registros) como tablas dinámicas, perforación, cálculos sobre la marcha, etc. con este método. La pregunta es: ¿ vale la pena el esfuerzo?
Bucles ajustados, como cuando se juega con imágenes, ya que una imagen puede contener millones de píxeles. Sentarse y descubrir cómo hacer un mejor uso del número limitado de registros del procesador puede marcar la diferencia. Aquí hay una muestra de la vida real:
http://danbystrom.se/2008/12/22/optimizing-away-ii/
Luego, a menudo los procesadores tienen algunas instrucciones esotéricas que son demasiado especializadas para que un compilador se moleste, pero en ocasiones un programador de ensambladores puede hacer un buen uso de ellas. Tome la instrucción XLAT por ejemplo. Realmente genial si necesita hacer búsquedas de tablas en un bucle y la tabla está limitada a 256 bytes.
Actualizado: Oh, solo piensen en lo que es más crucial cuando hablamos de bucles en general: el compilador a menudo no tiene idea de cuántas iteraciones será el caso común. Solo el programador sabe que un bucle se repetirá MUCHAS veces y que, por lo tanto, será beneficioso preparar el bucle con un poco de trabajo adicional, o si se repetirá tan pocas veces que la configuración realmente llevará más tiempo que las iteraciones esperado.
Dado el programador correcto, los programas de ensamblador siempre pueden hacerse más rápido que sus contrapartes en C (al menos marginalmente). Sería difícil crear un programa en C en el que no se pudiera sacar al menos una instrucción del ensamblador.
He leído todas las respuestas (más de 30) y no encontré una razón simple: el ensamblador es más rápido que C si ha leído y practicado el Manual de referencia de optimización de las arquitecturas Intel® 64 e IA-32 , por lo que la razón por la que el ensamblaje puede Ser más lento es que las personas que escriben un ensamblaje más lento no leyeron el Manual de optimización .
En los buenos tiempos de Intel 80286, cada instrucción se ejecutaba con un número fijo de ciclos de CPU, pero desde Pentium Pro, lanzada en 1995, los procesadores Intel se convirtieron en superscalar, utilizando la canalización compleja. Antes de eso, en Pentium, producido en 1993, había tuberías en U y V: tuberías de doble tubería que podían ejecutar dos instrucciones simples en un ciclo de reloj si no dependían una de otra; pero esto no fue nada para comparar lo que es la Ejecución fuera de orden y el cambio de nombre de registro apareció en Pentium Pro, y casi no se modifica en la actualidad.
Para explicar en pocas palabras, el código más rápido es donde las instrucciones no dependen de los resultados anteriores, por ejemplo, siempre debe borrar registros completos (por movzx) o usar add rax, 1
en su lugar o inc rax
para eliminar la dependencia del estado anterior de las banderas, etc.
Puede leer más sobre Ejecución fuera de orden y cambio de nombre de registro si el tiempo lo permite, hay mucha información disponible en Internet.
También hay otros problemas importantes como la predicción de sucursales, la cantidad de unidades de carga y almacenamiento, la cantidad de compuertas que ejecutan microoperaciones, etc., pero lo más importante a considerar es la ejecución fuera de orden.
La mayoría de las personas simplemente no están al tanto de la Ejecución fuera de orden, por lo que escriben sus programas de ensamblaje como para 80286, esperando que su instrucción tarde un tiempo fijo para ejecutarse independientemente del contexto; mientras que los compiladores de C son conscientes de la Ejecución fuera de orden y generan el código correctamente. Es por eso que el código de esas personas inconscientes es más lento, pero si te das cuenta, tu código será más rápido.
La respuesta simple ... Quien conoce bien el ensamblaje (alias tiene la referencia junto a él, y está aprovechando cada pequeño caché de procesador y característica de canalización, etc.) está garantizado para producir código mucho más rápido que cualquier compilador.
Sin embargo, la diferencia en estos días simplemente no importa en la aplicación típica.
Longpoke, solo hay una limitación: el tiempo. Cuando no tiene los recursos para optimizar cada cambio en el código y dedicar su tiempo a la asignación de registros, optimice algunos derrames de distancia y lo que no, el compilador ganará cada vez. Usted hace su modificación al código, recompile y mida. Repita si es necesario.
Además, puedes hacer mucho en el lado de alto nivel. Además, inspeccionar el ensamblaje resultante puede dar la IMPRESIÓN de que el código es una mierda, pero en la práctica funcionará más rápido de lo que cree que sería más rápido. Ejemplo:
int y = datos [i]; // hacer algunas cosas aquí .. call_function (y, ...);
El compilador leerá los datos, los empujará a la pila (derrame) y luego los leerá de la pila y los pasará como argumento. ¿Suena mal? En realidad podría ser una compensación de latencia muy efectiva y resultar en un tiempo de ejecución más rápido.
// versión optimizada call_function (data [i], ...); // no tan optimizado después de todo ...
La idea con la versión optimizada era que hemos reducido la presión de registro y evitar derrames. Pero en verdad, ¡la versión "de mierda" fue más rápida!
Mirando el código de ensamblaje, solo mirando las instrucciones y concluyendo: más instrucciones, más lentas, serían un error de juicio.
Lo que hay que prestar atención es: muchos expertos en ensamblaje creen que saben mucho, pero saben muy poco. Las reglas cambian de la arquitectura a la siguiente, también. No hay código x86 de Silver-bullet, por ejemplo, que siempre es el más rápido. Estos días es mejor ir por reglas de oro:
- la memoria es lenta
- el caché es rápido
- tratar de usar el caché mejor
- ¿Con qué frecuencia vas a extrañar? ¿Tienes estrategia de compensación de latencia?
- puede ejecutar 10-100 instrucciones ALU / FPU / SSE para una sola falta de caché
- La arquitectura de la aplicación es importante.
- ... pero no ayuda cuando el problema no está en la arquitectura
Además, confiar demasiado en el compilador transformando mágicamente el código C / C ++ mal pensado en código "teóricamente óptimo" es una ilusión. Debe conocer el compilador y la cadena de herramientas que utiliza si le interesa el "rendimiento" en este nivel bajo.
Los compiladores en C / C ++ generalmente no son muy buenos para reordenar las subexpresiones porque las funciones tienen efectos secundarios, para empezar. Los lenguajes funcionales no sufren esta advertencia, pero no encajan tan bien en el ecosistema actual. Hay opciones de compilador para permitir reglas de precisión relajadas que permiten que el orden del compilador / enlazador / generador de código cambie las operaciones.
Este tema es un poco un callejón sin salida; para la mayoría no es relevante, y el resto, ya saben lo que están haciendo.
Todo se reduce a esto: "para entender lo que estás haciendo", es un poco diferente de saber lo que estás haciendo.
Me sorprende que nadie haya dicho esto. La strlen()
función es mucho más rápida si se escribe en conjunto! En C, lo mejor que puedes hacer es
int c;
for(c = 0; str[c] != ''/0''; c++) {}
Mientras que en el montaje puedes acelerarlo considerablemente:
mov esi, offset string
mov edi, esi
xor ecx, ecx
lp:
mov ax, byte ptr [esi]
cmp al, cl
je end_1
cmp ah, cl
je end_2
mov bx, byte ptr [esi + 2]
cmp bl, cl
je end_3
cmp bh, cl
je end_4
add esi, 4
jmp lp
end_4:
inc esi
end_3:
inc esi
end_2:
inc esi
end_1:
inc esi
mov ecx, esi
sub ecx, edi
La longitud está en ecx. Esto compara 4 caracteres a la vez, por lo que es 4 veces más rápido. Y piense usando la palabra de alto orden de eax y ebx, ¡se volverá 8 veces más rápido que la rutina C anterior!
No puedo dar los ejemplos específicos porque fue hace muchos años, pero hubo muchos casos en los que el ensamblador escrito a mano podía superar a cualquier compilador. Razones por las cuales:
Podría desviarse de las convenciones de llamada, pasando argumentos en los registros.
Podría considerar cuidadosamente cómo usar registros y evitar almacenar variables en la memoria.
Para cosas como tablas de salto, podría evitar tener que verificar los límites del índice.
Básicamente, los compiladores hacen un buen trabajo de optimización, y eso casi siempre es "lo suficientemente bueno", pero en algunas situaciones (como la representación de gráficos) donde pagas caro por cada ciclo, puedes tomar atajos porque conoces el código , donde un compilador no pudo porque tiene que estar en el lado seguro.
De hecho, he oído hablar de algunos códigos de procesamiento de gráficos donde una rutina, como una línea de dibujo o un polígono, realmente generó un pequeño bloque de código de máquina en la pila y lo ejecutó allí, para evitar la toma de decisiones continua. sobre el estilo de línea, ancho, patrón, etc.
Dicho esto, lo que quiero que haga un compilador es generar un buen código de ensamblaje para mí, pero no ser demasiado inteligente, y en su mayoría lo hacen. De hecho, una de las cosas que odio de Fortran es codificar el código en un intento de "optimizarlo", generalmente sin ningún propósito significativo.
Normalmente, cuando las aplicaciones tienen problemas de rendimiento, se debe a un diseño inútil. En estos días, nunca recomendaría el ensamblador para el rendimiento a menos que la aplicación general ya hubiera sido ajustada a una pulgada de su vida útil, aún no fuera lo suficientemente rápida y pasara todo su tiempo en apretados bucles internos.
Agregado: He visto muchas aplicaciones escritas en lenguaje ensamblador, y la principal ventaja de la velocidad sobre un lenguaje como C, Pascal, Fortran, etc. fue porque el programador era mucho más cuidadoso al codificar en ensamblador. Él o ella escribirá aproximadamente 100 líneas de código por día, independientemente del idioma, y en un lenguaje de compilación que equivalga a 3 o 400 instrucciones.
Realmente no sabes si tu código C bien escrito es realmente rápido si no has visto el desmontaje de lo que produce el compilador. Muchas veces lo miras y ves que "bien escrito" fue subjetivo.
Por lo tanto, no es necesario escribir en ensamblador para obtener el código más rápido, pero ciertamente vale la pena conocer el ensamblador por la misma razón.
Uno de los fragmentos de ensamblaje más famosos proviene del bucle de mapeo de textura de Michael Abrash ( explicado en detalle aquí ):
add edx,[DeltaVFrac] ; add in dVFrac
sbb ebp,ebp ; store carry
mov [edi],al ; write pixel n
mov al,[esi] ; fetch pixel n+1
add ecx,ebx ; add in dUFrac
adc esi,[4*ebp + UVStepVCarry]; add in steps
Hoy en día, la mayoría de los compiladores expresan instrucciones específicas de CPU avanzadas como intrínsecas, es decir, funciones que se compilan hasta la instrucción real. MS Visual C ++ admite intrínsecos para MMX, SSE, SSE2, SSE3 y SSE4, por lo que debe preocuparse menos por el montaje para aprovechar las instrucciones específicas de la plataforma. Visual C ++ también puede aprovechar la arquitectura real a la que se dirige con la configuración apropiada / ARCH.
gcc se ha convertido en un compilador ampliamente utilizado. Sus optimizaciones en general no son tan buenas. Mucho mejor que el ensamblador de escritura programador promedio, pero para un rendimiento real, no tan bueno. Hay compiladores que son simplemente increíbles en el código que producen. Por lo tanto, como respuesta general, habrá muchos lugares en los que puede ingresar a la salida del compilador y ajustar el ensamblador para mejorar el rendimiento, o simplemente volver a escribir la rutina desde cero.