¿Qué técnicas de encoding utilizas para optimizar los progtwigs de C?

Hace algunos años participé en un panel que entrevistaba a candidatos para un puesto de progtwigdor de C integrado relativamente superior.

Una de las preguntas estándar que hice fue sobre técnicas de optimización. Me sorprendió bastante que algunos de los candidatos no tuvieran respuestas.

Entonces, con el fin de armar una lista para la posteridad, ¿qué técnicas y construcciones usas normalmente al optimizar los progtwigs de C?

Respuestas a la optimización de velocidad y tamaño ambas aceptadas.

Lo primero es lo primero, no optimices demasiado pronto. No es raro que dedique tiempo a optimizar con cuidado una parte del código solo para descubrir que no fue el cuello de botella lo que pensó que iba a ser. O, para decirlo de otra manera, “antes de que lo hagas rápido, hazlo funcionar”

Investigue si hay alguna opción para optimizar el algoritmo antes de optimizar el código. Será más fácil encontrar una mejora en el rendimiento optimizando un algoritmo deficiente que optimizar el código, y luego eliminarlo cuando se cambie el algoritmo de todos modos.

Y resuelva por qué necesita optimizar en primer lugar. ¿Qué estás intentando lograr? Si está intentando, digamos, mejorar el tiempo de respuesta a algún evento de ejercicio si existe la oportunidad de cambiar el orden de ejecución para minimizar las áreas de tiempo crítico. Por ejemplo, cuando intenta mejorar la respuesta a alguna interrupción externa, ¿puede hacer alguna preparación en el tiempo muerto entre eventos?

Una vez que haya decidido que necesita optimizar el código, ¿qué bit optimiza? Utilice un perfilador. Enfoca tu atención (primero) en las áreas que se usan con más frecuencia.

Entonces, ¿qué puedes hacer con esas áreas?

  • minimizar el control de la condición. Las condiciones de verificación (por ejemplo, las condiciones de terminación de los bucles) es un tiempo que no se gasta en el procesamiento real. La verificación de la condición se puede minimizar con técnicas como el desenrollado de bucle.
  • En algunas circunstancias, la verificación de la condición también puede eliminarse mediante el uso de punteros de función. Por ejemplo, si está implementando una máquina de estados, puede encontrar que implementar los controladores para estados individuales como funciones pequeñas (con un prototipo uniforme) y almacenar el “estado siguiente” almacenando el puntero de función del siguiente controlador es más eficiente que usar un Declaración de cambio grande con el código del controlador implementado en las declaraciones de casos individuales. YMMV.
  • minimizar llamadas a funciones. Las llamadas de función generalmente conllevan una carga de ahorro de contexto (por ejemplo, escribir variables locales contenidas en registros en la stack, guardar el puntero de la stack), por lo que si no tiene que hacer una llamada, este es el tiempo ahorrado. Una opción (si está optimizando la velocidad y no el espacio) es hacer uso de las funciones en línea.
  • Si las llamadas a funciones son inevitables, minimice los datos que se están pasando a las funciones. Por ejemplo, es probable que los punteros de paso sean más eficientes que las estructuras de paso.
  • Al optimizar la velocidad, elija tipos de datos que sean del tamaño nativo para su plataforma. Por ejemplo, en un procesador de 32 bits es probable que sea más eficiente manipular valores de 32 bits que valores de 8 o 16 bits. (nota al margen: vale la pena comprobar que el comstackdor está haciendo lo que crees que es. He tenido situaciones en las que he descubierto que mi comstackdor insistió en hacer aritmética de 16 bits en valores de 8 bits con todas las conversiones de ida y vuelta ir con ellos)
  • Encuentre datos que puedan precalcularse y calcule durante la inicialización o (mejor aún) en el momento de la comstackción. Por ejemplo, al implementar un CRC, puede calcular sus valores de CRC sobre la marcha (utilizando el polinomio directamente), lo que es excelente para el tamaño (pero terrible para el rendimiento), o puede generar una tabla de todos los valores provisionales, que es un Implementación mucho más rápida, en detrimento del tamaño.
  • Localiza tus datos. Si está manipulando un blob de datos a menudo, su procesador puede acelerar las cosas almacenándolas en caché. Y su comstackdor puede ser capaz de usar instrucciones más cortas que sean adecuadas para datos más localizados (por ejemplo, instrucciones que usen desplazamientos de 8 bits en lugar de 32 bits)
  • En el mismo sentido, localiza tus funciones. Por las mismas razones.
  • Determine las suposiciones que puede hacer acerca de las operaciones que está realizando y encuentre formas de explotarlas. Por ejemplo, en una plataforma de 8 bits, si la única operación que realiza en un valor de 32 bits es un incremento, puede encontrar que puede hacerlo mejor que el comstackdor incorporando (o creando una macro) específicamente para este propósito, En lugar de utilizar una operación aritmética normal.
  • Evite instrucciones costosas – la división es un buen ejemplo.
  • La palabra clave “registrar” puede ser su amigo (aunque es de esperar que su comstackdor tenga una buena idea sobre el uso de su registro). Si va a utilizar “registrar”, es probable que tenga que declarar primero las variables locales que desea que se “registren”.
  • Sea consistente con sus tipos de datos. Si está haciendo aritmética en una mezcla de tipos de datos (por ejemplo, cortos e ints, dobles y flotantes), el comstackdor está agregando conversiones de tipo implícitas para cada falta de coincidencia. Esto se desperdicia ciclos de cpu que pueden no ser necesarios.

La mayoría de las opciones enumeradas anteriormente se pueden usar como parte de la práctica normal sin ningún efecto adverso. Sin embargo, si realmente está tratando de obtener el mejor rendimiento: – Investigue dónde puede (de manera segura) deshabilitar la verificación de errores. No se recomienda, pero le ahorrará espacio y ciclos. – Manualidades manuales de tu código en ensamblador. Esto, por supuesto, significa que su código ya no es portátil, pero donde eso no es un problema, puede encontrar ahorros aquí. Sin embargo, tenga en cuenta que es posible que pierda tiempo moviendo los datos dentro y fuera de los registros que tiene a su disposición (es decir, para satisfacer el uso del registro de su comstackdor). También tenga en cuenta que su comstackdor debería estar haciendo un buen trabajo por sí solo. (por supuesto hay excepciones)

Como todos los demás han dicho: perfil, perfil perfil.

En cuanto a las técnicas reales, una que no creo que haya sido mencionada todavía:

Separación de datos en caliente y en frío : mantenerse dentro de la memoria caché de la CPU es increíblemente importante. Una forma de ayudar a hacer esto es dividiendo sus estructuras de datos en secciones de acceso frecuente (“caliente”) y de acceso raro (“frío”).

Un ejemplo: suponga que tiene una estructura para un cliente que se parece a esto:

struct Customer { int ID; int AccountNumber; char Name[128]; char Address[256]; }; Customer customers[1000]; 

Ahora, supongamos que desea acceder a la ID y AccountNumber mucho, pero no tanto el nombre y la dirección. Lo que harías es dividirlo en dos:

 struct CustomerAccount { int ID; int AccountNumber; CustomerData *pData; }; struct CustomerData { char Name[128]; char Address[256]; }; CustomerAccount customers[1000]; 

De esta manera, cuando recorres la matriz de “clientes”, cada entrada es de solo 12 bytes, por lo que puedes incluir muchas más entradas en el caché. Esto puede ser una gran ganancia si puede aplicarlo a situaciones como el bucle interno de un motor de renderizado.

Mi técnica favorita es usar un buen perfilador. Sin un buen perfil que le diga dónde está el cuello de botella, no hay trucos ni técnicas que lo ayuden.

Las técnicas más comunes que encontré son:

  • desenrollado de bucle
  • Optimización de bucle para una mejor captación previa de caché (es decir, realizar operaciones con N en ciclos M en lugar de operaciones singulares NxM)
  • alineación de datos
  • funciones en línea
  • fragmentos de asm hechos a mano

En cuanto a las recomendaciones generales, la mayoría de ellas ya son sonadas:

  • elige mejores algos
  • usar perfilador
  • no optimice si no le da un aumento de rendimiento de 20-30%

Para la optimización de bajo nivel:

  1. START_TIMER / STOP_TIMER macros de ffmpeg (precisión de nivel de reloj para la medición de cualquier código).
  2. Oprofile, por supuesto, para perfilar.
  3. Enormes cantidades de ensamblados codificados a mano (solo haga un wc -l en el directorio x264 / common / x86, y luego recuerde que la mayor parte del código tiene una plantilla).
  4. Codificación cuidadosa en general; El código más corto suele ser mejor.
  5. Algoritmos inteligentes de bajo nivel, como el escritor de flujo de bits de 64 bits que escribí, que usa solo uno si no otro.
  6. Combinación explícita de escritura .
  7. Teniendo en cuenta aspectos extraños importantes de los procesadores, como el problema de división de cacheline de Intel .
  8. Encontrar casos en los que se pueda realizar una cancelación anticipada sin pérdidas o casi sin pérdidas, donde la verificación de la terminación anticipada cuesta mucho menos que la velocidad que se obtiene de ella.
  9. Ensamblaje realmente en línea para tareas que son mucho más adecuadas para la unidad SIMD x86, como cálculos de mediana (requiere verificación en tiempo de comstackción para el soporte de MMX).
  • En primer lugar, utilice un algoritmo mejor / más rápido. No hay un punto que optimice el código que es lento por diseño.
  • Al optimizar la velocidad, cambie la memoria por la velocidad: tablas de búsqueda de valores precalculados, árboles binarios, escriba una implementación personalizada más rápida de las llamadas al sistema …
  • Al intercambiar velocidad por memoria: use compresión en memoria

Evite utilizar el montón. Utilice obstáculos o asignador de grupo para objetos de tamaño idéntico. Pon las cosas pequeñas con una vida corta en la stack. todavia existe alloca

¡La optimización temprana es la raíz de todo mal! 😉

Como mis aplicaciones no suelen necesitar mucho tiempo de CPU por diseño, me concentro en el tamaño de mis binarios en el disco y en la memoria. Lo que hago principalmente es buscar arreglos de tamaño estático y reemplazarlos con memoria asignada dinámicamente, donde vale la pena el esfuerzo adicional de liberar la memoria más tarde. Para reducir el tamaño del binario, busco matrices grandes que se inicializan en tiempo de comstackción y pongo la inicialización en tiempo de ejecución.

 char buf[1024] = { 0, }; /* becomes: */ char buf[1024]; memset(buf, 0, sizeof(buf)); 

Esto eliminará los 1024 cero bytes de la sección .DATA de los binarios y, en su lugar, creará el búfer en la stack en tiempo de ejecución y lo llenará con ceros.

EDIT: Oh sí, y me gusta cachear las cosas. No es específico de C, pero dependiendo de lo que esté almacenando en caché, puede darle un gran impulso en el rendimiento.

PD: Por favor, háganos saber cuando su lista esté terminada, tengo mucha curiosidad. 😉

Si es posible, compare con 0, no con números arbitrarios, especialmente en bucles, porque la comparación con 0 a menudo se implementa con comandos de ensamblador más rápidos y separados.

Por ejemplo, si es posible, escriba

 for (i=n; i!=0; --i) { ... } 

en lugar de

 for (i=0; i!=n; ++i) { ... } 

Otra cosa que no se mencionó:

  • Conozca sus requisitos: no se optimice para situaciones improbables o nunca sucederá, concéntrese en lo más rentable posible.

fundamentos / general

  • No optimices cuando no tengas ningún problema.
  • Conoce tu plataforma / CPU …
  • … saberlo a fondo
  • conoce tu ABI
  • Deje que el comstackdor haga la optimización, solo ayúdelo con el trabajo.

Algunas cosas que realmente han ayudado:

Optar por tamaño / memoria:

  • Usar campos de bits para almacenar bools.
  • reutilizar grandes arreglos globales mediante superposición con una unión (tenga cuidado)

Opta por la velocidad (ten cuidado):

  • usar tablas precomputadas cuando sea posible
  • Coloca funciones / datos críticos en la memoria rápida
  • Utilice registros dedicados para los globos de uso frecuente.
  • contar hasta cero, cero bandera es gratis

Difícil de resumir …

  • Estructuras de datos:

    • La división de una estructura de datos en función del caso de uso es extremadamente importante. Es común ver una estructura que contiene datos a los que se accede según un control de flujo. Esta situación puede reducir significativamente el uso de caché.
    • Para tener en cuenta el tamaño de línea de caché y las reglas de captación previa.
    • Para reordenar a los miembros de la estructura para obtener un acceso secuencial a ellos desde su código
  • Algoritmos:

    • Tómese tiempo para pensar en su problema y para encontrar el algoritmo correcto.
    • Conozca las limitaciones del algoritmo que elija (una selección de radix / orden rápida para 10 elementos a clasificar no puede ser la mejor opción).
  • Nivel bajo:

    • En cuanto a los últimos procesadores, no se recomienda desenrollar un bucle que tenga un cuerpo pequeño. El procesador proporciona su propio mecanismo de detección para esto y cortocircuitará toda la sección de su canalización.
    • Confíe en el prefetcher HW. Por supuesto si sus estructuras de datos están bien diseñadas;)
    • Cuidado con su línea de caché L2 pierde.
    • Intente reducir al máximo el conjunto de trabajo local de su aplicación, ya que los procesadores se inclinan a cachés más pequeños por núcleo (C2D disfrutó de un máximo de 3 MB por núcleo, donde iCore7 proporcionará un máximo de 256 KB por núcleo + 8 MB compartido a todos los núcleos para un matriz de cuatro núcleos.).

Lo más importante de todo: mida temprano, mida con frecuencia y nunca haga suposiciones, base su pensamiento y optimizaciones en los datos recuperados por un generador de perfiles (use PTU ).

Otra sugerencia, el rendimiento es clave para el éxito de una aplicación y debe considerarse en el momento del diseño y debe tener objectives de rendimiento claros.

Esto está lejos de ser exhaustivo, pero debería proporcionar una base interesante.

En estos días, las cosas más importantes en la optimización son:

  • Respetar el caché: intente acceder a la memoria en patrones simples y no desenrolle los bucles solo por diversión. Utilice matrices en lugar de estructuras de datos con mucha persecución de punteros y probablemente será más rápido para pequeñas cantidades de datos. Y no hagas nada demasiado grande.
  • evitar la latencia: intente evitar divisiones y cosas que son lentas si otros cálculos dependen de ellas inmediatamente. Los accesos de memoria que dependen de otros accesos de memoria (es decir, a [b [c]]) son incorrectos.
  • Evitar la imprevisibilidad: muchos de los casos con condiciones impredecibles, o condiciones que introducen una mayor latencia, realmente lo estropearán. Hay muchos trucos matemáticos sin sucursales que son útiles aquí, pero aumentan la latencia y solo son útiles si realmente los necesitas. De lo contrario, simplemente escriba un código simple y no tenga condiciones de bucle alocadas.

No se moleste con las optimizaciones que involucran copiar y pegar su código (como desenrollar bucles), o reordenar los bucles a mano. El comstackdor generalmente hace un mejor trabajo que usted al hacer esto, pero la mayoría de ellos no son lo suficientemente inteligentes como para deshacerlo.

La recostackción de perfiles de ejecución de código te permite llegar al 50% del camino. El otro 50% se ocupa de analizar estos informes.

Además, si usa GCC o VisualC ++, puede usar la “optimización guiada por perfil” donde el comstackdor tomará información de ejecuciones previas y reprogtwigrá las instrucciones para hacer que la CPU sea más feliz.

Funciones en línea! Inspirado por los fanáticos de los perfiles, presenté una aplicación mía y encontré una pequeña función que hace cambios de bits en los marcos de MP3. Hace aproximadamente el 90% de todas las llamadas a funciones en mi aplicación, así que lo hice en línea y listo: el progtwig ahora usa la mitad del tiempo de CPU que tenía antes.

En la mayoría de los sistemas integrados en los que trabajé no había herramientas de creación de perfiles, por lo que es bueno decir usar profiler pero no muy práctico.

La primera regla en la optimización de la velocidad es: encuentre su ruta crítica .
Por lo general, encontrará que este camino no es tan largo ni tan complejo. Es difícil decir de manera genérica cómo optimizar esto, depende de lo que esté haciendo y de lo que esté en su poder de hacer. Por ejemplo, por lo general, desea evitar memcpy en la ruta crítica, por lo que siempre debe usar DMA u optimizar, pero ¿qué sucede si no tiene DMA? compruebe si la implementación de memcpy es la mejor si no la reescribe.
No utilice la asignación dinámica en absoluto incrustada, pero si lo hace por alguna razón, no lo haga en la ruta crítica.
Organice sus prioridades de hilo correctamente, lo que es correcto es una pregunta real y es claramente específico del sistema.
Utilizamos herramientas muy simples para analizar los cuellos de botella, macro simple que almacena la marca de tiempo y el índice. Pocos (2-3) se ejecutan en el 90% de los casos donde encontrará su tiempo.
Y el último es el código de revisión uno muy importante. En la mayoría de los casos, evitamos problemas de rendimiento durante la revisión del código de manera muy efectiva 🙂

  1. Medida de rendimiento.
  2. Utilice puntos de referencia realistas y no triviales. Recuerda que “todo es rápido para la pequeña N” .
  3. Utilice un generador de perfiles para encontrar puntos de acceso.
  4. Reduzca la cantidad de asignaciones de memoria dinámica, accesos a disco, accesos a bases de datos, accesos a la red y transiciones de usuario / kernel, ya que estos suelen ser puntos de acceso.
  5. Medida de rendimiento.

Además, debes medir el rendimiento.

A veces, tiene que decidir si lo que busca es más espacio o más velocidad, lo que llevará a optimizaciones casi opuestas. Por ejemplo, para aprovechar al máximo su espacio, puede empaquetar estructuras, por ejemplo, #pragma pack (1) y utilizar campos de bits en las estructuras. Para obtener más velocidad, empaquete para alinearse con las preferencias de los procesadores y evitar los campos de bits.

Otro truco es elegir los algoritmos de redimensionamiento correctos para el desarrollo de matrices a través de realloc, o mejor aún, escribir su propio administrador de stacks basado en su aplicación particular. No asum que la que viene con el comstackdor es la mejor solución posible para cada aplicación.

Si alguien no tiene una respuesta a esa pregunta, podría ser que no sepan mucho.

También podría ser que sepan mucho. Sé mucho (IMHO :-), y si me hicieran esa pregunta, les estaría respondiendo: ¿Por qué creen que es importante?

El problema es que todas las nociones a priori sobre el rendimiento, si no están informadas por una situación específica, son suposiciones por definición.

Creo que es importante conocer las técnicas de encoding para el rendimiento, pero creo que es aún más importante saber no usarlas , hasta que el diagnóstico revele que existe un problema y cuál es.

Ahora me contradeciré y le diré que, si lo hace, aprenderá a reconocer los enfoques de diseño que llevan a los problemas para evitarlos, y para un principiante, eso suena como una optimización prematura.

Para darle un ejemplo concreto, esta es una aplicación C que fue optimizada .

Grandes listas. Solo agregaré una sugerencia que no vi en las listas anteriores, que en algunos casos puede brindar una gran optimización a un costo mínimo.

  • enlazador de bypass

    Si tiene alguna aplicación dividida en dos archivos, digamos main.c y lib.c, en muchos casos, simplemente puede agregar un \#include "lib.c" en su main.c Eso omitirá completamente el enlazador y permitirá mucho más Optimización eficiente para comstackdor.

Se puede lograr el mismo efecto optimizando las dependencias entre archivos, pero el costo de los cambios suele ser mayor.

A veces Google es la mejor herramienta de optimización de algoritmos. Cuando tengo un problema complejo, un poco de búsqueda revela que algunos individuos con doctorados han encontrado un mapeo entre este y un problema conocido y ya han hecho la mayor parte del trabajo.

Recomendaría optimizar el uso de algoritmos más eficientes y no hacerlo como una idea de último momento, sino codificarlo desde el principio. Deje que el comstackdor resuelva los detalles de las cosas pequeñas, ya que sabe más sobre el procesador de destino que usted.

Por un lado, rara vez uso bucles para buscar cosas, agrego elementos a una tabla hash y luego utilizo la tabla hash para buscar los resultados.

Por ejemplo, tiene una cadena para buscar y luego 50 valores posibles. Entonces, en lugar de hacer 50 strcmps, agrega todas las 50 cadenas a una tabla hash y le da a cada uno un número único (solo tiene que hacerlo una vez). Luego busca la cadena de destino en la tabla hash y tiene un interruptor grande con los 50 casos (o tiene punteros de funciones).

Cuando busco cosas con conjuntos comunes de entrada (como las reglas de css), utilizo un código rápido para hacer un seguimiento de las únicas soliciones posibles y luego itero las palabras para encontrar una coincidencia. Una vez que tengo una coincidencia, guardo los resultados en una tabla hash (como caché) y luego uso los resultados de la caché si obtengo el mismo conjunto de entrada más adelante.

Mis principales herramientas para un código más rápido son:

hashtable – para búsquedas rápidas y para resultados de caché

qsort – es el único tipo que uso

bsp – para buscar cosas en función del área (representación de mapas, etc.)