¿Debo refactorizar mi código C para optimizarlo para un microcontrolador integrado?

A continuación se muestra un código implementado en un microcontrolador de 8 bits.

El siguiente comentario fue publicado en otra pregunta :

Como su código no usa la ivariable, ¿por qué no solo while(length--)en lugar del primer forciclo?

Sugiere cambiar el for(..)bucle por un while(..), ¿supondrá esto una diferencia práctica en la optimización del código para un microcontrolador integrado?

uint32_t MyFunction(uint32_t crc, uint8_t *buffer, uint16_t length) {
    for (uint16_t i=0; i < length; i++) { 
        crc = crc ^ *buffer++; 

        for (uint16_t j=0; j < 8; j++) { 
           if (crc & 1) 
              crc = (crc >> 1) ^ 0xEDB88320; 
           else 
              crc = crc >> 1; 
        } 
    } 

   return crc; 
}

Nota: Escribí esta pregunta y la respondí yo mismo después de publicar un comentario a principios de esta semana. Ese comentario fue votado a favor, pero sentí que debería respaldarlo con algunas pruebas reales. Serían útiles otras respuestas, pero esto pretendía ser una prueba genérica de un punto en lugar de una pregunta específica sobre el algoritmo.

Si esto está en una máquina de 8 bits, ¿por qué forzar deliberadamente que J sea un número entero de 16 bits cuando solo contará de 0 a 7?
¿Lo ha perfilado para ver si realmente necesita optimizarlo?
Los microcontroladores suelen incluir una instrucción que decrementa y salta si es igual/diferente de cero. Incrementar y comparar con una constante o variable implica al menos una instrucción más por bucle. Entonces, como una buena práctica general en términos de tiempo, haga bucles que usen un contador que disminuya a cero cuando termine.
La única razón por la que escribí esto es porque di un consejo en otro hilo que quería demostrar que valía la pena en algunas circunstancias limitadas.
@OlinLathrop punto interesante, me pregunto si XC8 o GCC lo detectaron y lo optimizaron. Eso debería ser una captura fácil para el compilador.
@OlinLathrop probó y eso ahorra aún más espacio, mi respuesta se actualizó a continuación. Gracias por la anotación.

Respuestas (3)

Las tres reglas clásicas de optimización:

  1. no.
  2. todavía no
  3. perfil antes de optimizar.

No está de más escribir un código limpio y ordenado en primer lugar, pero la optimización prematura es una pérdida de tiempo y esfuerzo que podría gastarse de manera más fructífera en otro lugar.

Las rutinas de CRC de búfer a menudo pueden ser un cuello de botella en micros de 8 bits, incluso cuando están escritas de manera óptima . Además, muchos micros de 8 bits, algo así como el código citado, es probablemente mucho más lento de lo que sería el código óptimo. Para muchos propósitos, el código C puede acercarse lo suficiente al ensamblaje como para que no valga la pena optimizarlo más, pero ciertos tipos de construcciones de bucle requieren optimizaciones que ningún compilador que yo conozca pueda hacer.
Al punto de Brick. "[...] la optimización prematura es la raíz de todos los males" según Donald Knuth.
Realmente odio lo lejos que la gente lleva el comentario de Knuth. Al menos debe prestar atención mientras escribe el código para que sea razonablemente eficiente desde el principio. La gente cita a Knuth como una excusa para su código descuidado, y también se justifica por ello. Debe desarrollar buenas prácticas de programación que lo ayuden a corregir un buen código. No estoy diciendo que desensamble y canalice el análisis de todo... pero como dice @supercat, si está prestando atención a su problema, es probable que se dé cuenta de cuánto trabajo va a hacer su algoritmo CRC. En muchas situaciones será absolutamente importante.
una rutina CRC en un procesador de 8 bits podría no necesitar ser eficiente en absoluto, o podría ser absolutamente crítica. Sin más detalles, este NO es un buen ejemplo para la "prueba genérica" ​​del OP a favor o en contra de optimizarlo.
@darron: La palabra clave es la optimización prematura , especialmente en los casos en los que prestar demasiada atención a la optimización de una parte de un programa puede hacer que uno descuide una parte más importante que representa un cuello de botella importante (por ejemplo, recuerdo haberme hecho cargo de un programa que alguien escribió para un DSP de punto fijo. Todo estaba escrito en código ensamblador excepto las matemáticas, que estaban escritas en C usando punto flotante, y IIRC requirió alrededor de 60 ms para procesar cada 25 ms de audio). Creo que mi reescritura terminó con unos cientos de líneas de código ensamblador para controladores de interrupciones, unas pocas docenas para matemáticas y...
...casi todo lo demás en C. Ni siquiera me molesté en intentar escribir las rutinas matemáticas en C. La parte del código ensamblador del procesamiento matemático probablemente tomó alrededor del 5% del tiempo total de la CPU, y C habría sido al menos 3-5 veces más lento. La utilización de la CPU con matemáticas de punto fijo en C podría haber estado por debajo del 100 %, pero es mucho más fácil razonar sobre los tiempos cuando la carga es baja que cuando es alta.
@supercat nota que la declaración de la regla de RedGrittyBrick no menciona la palabra "prematuro". Al menos aparece después... pero MUCHAS personas simplemente omiten esa palabra por completo. Supongo que lo que más me molesta aquí es que creo que esta lógica de "optimizar solo cuando sea necesario" generalmente conduce a una arquitectura deficiente... porque muchos de los problemas de rendimiento son arquitectónicos y es realmente doloroso cambiar eso más adelante. Creo que Knuth estaba asumiendo un nivel básico de competencia que lamentablemente no se refleja en la realidad en estos días.
@darron: interpreto la declaración de Knuth como "Optimizar antes de saber dónde se necesita a menudo conducirá a perder el tiempo optimizando cosas que no importan en lugar de cosas [no necesariamente solo el rendimiento] que sí lo hacen . No creo que la creación de perfiles siempre sea necesaria ; al elegir entre un algoritmo O(N^3) simple o uno complejo O(NlgN), por ejemplo, saber que N podría llegar a 1000 sería un argumento bastante sólido en contra del primero, incluso sin perfilar, y saber que N rara vez exceder de 3 y nunca exceder de 5 sería un fuerte argumento en contra de esto último.
@supercat Bueno, desde luego desearía que lo hubiera dicho a tu manera. Todo tu párrafo, de verdad. Lo estás diciendo mejor que yo. :)
Un ingeniero que constantemente supera las especificaciones está perdiendo tiempo y dinero

Lo primero que hay que tener en cuenta es lo que hay que optimizar. ¿El código necesita ocupar menos memoria de programa o debería ejecutarse más rápido? A veces, estas cosas se pueden lograr reduciendo la cantidad de instrucciones, sin embargo, esto depende del microcontrolador subyacente y la cantidad de ciclos que toma cada instrucción.

Es completamente posible generar código más pequeño que requiera más ciclos para ejecutarse, especialmente si hay sucursales involucradas.

Para comenzar a experimentar, es importante aislar la porción de código más pequeña posible. Creé el caso de prueba simple a continuación que incluye la función de la pregunta y que es portátil entre diferentes familias de microcontroladores:

#include <stdint.h>

uint32_t MyFunction(uint32_t crc, uint8_t *buffer, uint16_t length) {
    uint16_t i;
    uint16_t j;

    for (i = 0; i < length; i++) {
        crc = crc ^ *buffer++;

        for (j = 0; j < 8; j++) {
           if (crc & 1) 
              crc = (crc >> 1) ^ 0xEDB88320; 
           else 
              crc = crc >> 1;
        }
    }

   return crc;
}

int main(int argc, char** argv) {
    uint8_t data[] = "ABCDEF";
    uint32_t ret = 0;
    ret = MyFunction(ret, data, 6);
    while(1);
}

Como se señaló en los comentarios sobre la otra pregunta, el valor de la variable inunca se usa directamente, solo se compara con length. Por lo tanto, podríamos reescribirlo de la siguiente manera:

uint32_t MyFunction(uint32_t crc, uint8_t *buffer, uint16_t length) {
    uint16_t j;

    while (length--) {
      // .. do work ..
    }
    return crc;
}

Para fines de comparación, utilicé la versión 4.7.2 de avr-gcc (dirigida a atmega8) y XC8 1.21 de Microchip (dirigida a PIC 18F). Para XC8 habilité optimizaciones PRO, para avr-gcc los argumentos dados fueron: avr-gcc -g -c -Os -Wall -mmcu=atmega8 test.c -o test-Os.

Tenga en cuenta que es importante verificar que retno esté optimizado porque el compilador cree que no se usa. Ambas variaciones del código C generan el mismo ensamblado a partir de avr-gcc. Sin embargo, XC8 no nota la variable no utilizada, por lo tanto, el código que usa un while(..)bucle se compila en 4 bytes más pequeños con 2 bytes de RAM guardados.

Como se señaló en un comentario sobre esta pregunta, también sería más eficiente hacer jun uint8_t, ya que la entrada nunca tendrá más de 8 bits de ancho. Las pruebas en XC8 muestran que hacer este cambio ahorra otros 8 bytes de espacio de programa y 1 byte de RAM. También reduce la salida generada con avr-gcc en 4 bytes y un byte de registro.

En conclusión , siempre vale la pena darle al compilador la mejor oportunidad de generar un buen código , en este caso al no usar variables adicionales innecesarias y al usar la clase de almacenamiento más pequeña posible. Algunos compiladores de optimización se las arreglarán mejor que otros, pero ambos funcionarán bien si el código C de entrada se ha pensado bien.

Puede optimizar para otras cosas además de la velocidad o el tamaño. La optimización de la eficiencia energética es otra opción popular, pero el tiempo de respuesta o la consistencia del tiempo de respuesta también son otras.

Este tipo de cosas se pueden reescribir en muchos procesadores en una pequeña rutina de lenguaje ensamblador que se ejecutará mucho más rápido que cualquier posible implementación de C. En un PIC, por ejemplo, el cálculo de CRC podría escribirse usando 72 instrucciones si _crc está en el banco seleccionado actualmente (código para PIC de la serie 16F)

    movf  _crc+1,w
    btfss _crc,0
     xorlw   0xNN  ; Would need to figure out proper value
    btfss _crc,1
     xorlw   0xNN  ; Would need to figure out proper value
    .. six more such instruction pairs
    movwf btemp+0  ; LSB of return
    movf  _crc+2,w
    btfss _crc,0
     xorlw   0xNN  ; Would need to figure out proper value
    btfss _crc,1
     xorlw   0xNN  ; Would need to figure out proper value
    .. six more such instruction pairs
    movwf btemp+1 ; Next byte of return
    movf  _crc+3,w
    .. eight more instruction pairs
    movwf btemp+2,w
    movlw 0
    .. eight more instruction pairs
    movwf btemp+3

No es lo más compacto del mundo, pero actualizaría el CRC32 para un byte entrante en 72 ciclos. Alternativamente, si uno estuviera usando una parte de la serie 18F y pudiera ahorrar el espacio del código, uno podría usar tablas por valor de 1 Kbyte (organizadas como cuatro piezas de 256 bytes alineadas con la página). El código sería entonces algo como:

    movf  _crc,w,b
    movwf _TBLPTRL,c
    movlw TabUpperAddress
    movwf _TBLPTRU,c
    movlw TabHighAddress
    movwf _TBLPTRH,c
    tblrd *
    movff _TABLAT,btemp+3
    incf  _TBLPTRH,c
    tblrd *
    xorwf _crc+1,w,b
    movff _TABLAT,btemp+0
    incf  _TBLPTRH,c
    tblrd *
    xorwf _crc+2,w,b
    movff _TABLAT,btemp+1
    incf  _TBLPTRH,c
    tblrd *
    xorwf _crc+3,w,b
    movff _TABLAT,btemp+2

Un poco más rápido, pero requeriría 1 Kbytes de tablas de datos además del código en sí.