¿Cuándo el ensamblaje es más rápido que C? [cerrado]

Resuelto Adam Bellaire asked hace 15 años • 40 respuestas

Una de las razones declaradas para conocer el ensamblador es que, en ocasiones, se puede utilizar para escribir código que tendrá más rendimiento que escribir ese código en un lenguaje de nivel superior, C en particular. 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 código de mayor rendimiento son extremadamente raros y requieren conocimiento experto y experiencia con el ensamblador.

Esta pregunta ni siquiera aborda el hecho de que las instrucciones del ensamblador serán específicas de la máquina y no portátiles, ni de ninguno de los otros aspectos del ensamblador. Hay muchas buenas razones para conocer el ensamblador además de esta, por supuesto, pero esta debe ser una pregunta específica que solicite ejemplos y datos, no un discurso extenso sobre ensamblador versus lenguajes de nivel superior.

¿Alguien puede proporcionar algunos ejemplos específicos de casos en los que el ensamblaje será más rápido que un código C bien escrito utilizando un compilador moderno, y puede respaldar esa afirmación con evidencia de elaboración de perfiles? Estoy bastante seguro de que estos casos existen, pero realmente quiero saber exactamente qué tan esotéricos son, ya que parece ser un punto de controversia.

Adam Bellaire avatar Feb 23 '09 20:02 Adam Bellaire
Aceptado

Aquí hay 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 flotante 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 casi uniforme ( float).


Los compiladores modernos optimizan muy bien este ejemplo de punto fijo, por lo que para ejemplos más modernos que aún necesitan código específico del compilador, consulte

  • Obtener la parte alta de la multiplicación de enteros de 64 bits : una versión portátil que utiliza uint64_tmultiplicaciones de 32x32 => 64 bits no logra optimizar en una CPU de 64 bits, por lo que necesita __int128código intrínseco o 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 a 64, por lo que los intrínsecos ayudaron mucho.

C no tiene un operador de multiplicación completo (resultado de 2N bits a partir de 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 lenguaje C. Queremos multiplicar dos números de 32 bits y obtener un resultado de 64 bits del cual devolvemos los 32 bits del medio. Sin embargo, en C esta multiplicación no existe. Todo lo que puedes hacer es promover los números enteros a 64 bits y multiplicar 64*64 = 64.

Sin embargo, x86 (y ARM, MIPS y otros) pueden multiplicar en una sola instrucción. Algunos compiladores solían ignorar este hecho y generar código que llama a una función de biblioteca en tiempo de ejecución para realizar la multiplicación. El cambio a 16 también suele realizarse mediante una rutina de biblioteca (también el x86 puede realizar dichos cambios).

Así que nos quedan 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 a funciones y tampoco ayuda a insertar ni desenrollar el código.

Si reescribe el mismo código en 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 desplazamiento de 64 bits como __ll_rshift.

Usando elementos intrínsecos, puede reescribir la función de manera que el compilador de C tenga la oportunidad de comprender lo que está sucediendo. Esto permite insertar el código, asignar registros, eliminar subexpresiones comunes y también se puede realizar una propagación constante. De esta manera obtendrá una enorme mejora en el rendimiento con respecto al código ensamblador escrito a mano.

Como referencia: el resultado final para el 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 punto fijo es aún mayor. Obtuve mejoras de hasta el factor 10 para la división de código de punto fijo pesado al escribir un par de líneas ensambladas.


El uso de Visual C++ 2013 proporciona el mismo código ensamblador en ambos sentidos.

gcc4.1 de 2007 también optimiza muy bien la versión C pura. (El explorador del compilador Godbolt no tiene ninguna versión anterior de gcc instalada, pero presumiblemente incluso las versiones más antiguas de GCC podrían hacer esto sin intrínsecos).

Consulte fuente + asm para x86 (32 bits) y ARM en el explorador del compilador Godbolt . (Desafortunadamente, no tiene compiladores lo suficientemente antiguos como para producir código incorrecto a partir de la versión C pura y simple).


Las CPU modernas pueden hacer cosas para las que C no tiene operadores , como popcnto escaneo de bits para encontrar el primer o último bit establecido . (POSIX tiene una ffs()función, 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 el número de bits establecidos en un número entero y compilarlo en una popcntinstrucción (si está habilitado en el momento de la compilación), pero es mucho más confiable usarlo __builtin_popcnten GNU C o en x86 si solo está apuntando a hardware con SSE4.2: _mm_popcnt_u32desde<immintrin.h> .

O en C++, asígnelo a std::bitset<32>y use .count(). (Este es un caso en el que el lenguaje ha encontrado una manera de exponer de forma 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 todo lo que admita el destino). Consulte también https ://en.wikipedia.org/wiki/Hamming_weight#Language_support .

De manera similar, ntohlse puede compilar en bswap(intercambio de bytes x86 de 32 bits para conversión endian) en algunas implementaciones de C que lo tienen.


Otra área importante para los intrínsecos o los conjuntos 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 funcionan mal o no vectorizan automáticamente cuando las cosas se vuelven más complicadas. 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.

Nils Pipenbrinck avatar Feb 23 '2009 14:02 Nils Pipenbrinck

Hace muchos años le 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 reformular el problema usando cambios de bits, y el tiempo de proceso se redujo a unos 30 segundos en el compilador que no estaba optimizado que tenía.

Acababa de obtener un compilador de optimización y el mismo código giró el gráfico en <5 segundos. Miré el código ensamblador que estaba generando el compilador y, por lo que vi, decidí en ese momento que mis días de escribir ensamblador habían terminado.

Peter Cordes avatar Feb 23 '2009 13:02 Peter Cordes