¿Por qué la operación de módulo consume más energía?

Principalmente uso dispositivos Cortex-M4F o Cortex-M0/0+ como:

  • STM32L4, G4
  • STM32L0, G0

A veces veo blogs como este que dicen "Evitar módulo" para reducir el consumo de energía.

Comparando estos dos programas, ¿es esta una forma adecuada de evitar la operación de módulo?

Suponga que el voltaje de suministro de la MCU podría ser de 1,8 V o 3,3 V.

while(1) { // CODE X, I thought removing conditional statements could perform better
    my_index++;
    if (my_index >= 8) my_index = 0;
}

contra

while(1) { // CODE Y
    my_index = (my_index + 1) % 8;
}

Además, ¿por qué la operación de módulo consume más?

Debe comparar el código ensamblador generado para ambas versiones.
aquí está el ensamblaje para la operación de módulo sin firmar de la "biblioteca estándar" LLVM (un backend del compilador) necesita crear un ensamblaje ARM. Puede ver que son un poco más instrucciones que una instrucción if. code.woboq.org/llvm/compiler-rt/lib/builtins/arm/…
"módulo 8" no se implementará en la práctica como una operación de módulo (por cualquier compilador decente, de todos modos). Hay implementaciones más fáciles y más eficientes energéticamente para cualquier "módulo 2^n".
La comparación que usa OP no es buena porque una operación más "eficiente" se ejecutará más veces por segundo debido al ciclo while, lo que consumirá más energía.
El código eficiente mejora el consumo de energía cuando aumenta la oportunidad de que la CPU entre en un estado de suspensión, no hay exactamente ninguna oportunidad para eso en ninguno de los ejemplos dados. Entonces, a menos que las instrucciones en un ejemplo usen más energía por ciclo de reloj , ambos ejemplos usarán la misma cantidad de energía y el segundo usará menos tiempo (y por lo tanto menos energía) por iteración.
Si un sistema se despertará cuando sea necesario para realizar un trabajo y volverá a dormirse una vez que haya terminado hasta que haya más trabajo por hacer, entonces la cantidad de tiempo requerida para realizar una parte del trabajo se correlacionará fuertemente con la cantidad de energía requerida para realizar esa parte del trabajo, incluso si el código que realmente hace el trabajo es independiente de la noción de consumo de energía.

Respuestas (2)

Ese conjunto de reglas tiene sentido, más o menos. Pero son limitados.

Específicamente, la regla general "no usar módulo" es algo equivocada y realmente debería significar "evitar el uso de código que resulte en una operación de división".

(Mi conjunto de reglas sería comprender cómo funciona el hardware, compilar para ensamblar e inspeccionar el resultado, perfilar su código y comparar, comparar, comparar).

Si tenía una línea de código que decía a = b % c;entonces (suponiendo que a, b y c son números enteros), le está especificando al compilador que cpodría ser cualquier valor entero . Tendría que compilar en una operación de división. Las operaciones de división toman mucho tiempo o área lógica; en cualquier caso, eso se traduce en energía consumida para realizar una división.

En su caso específico , donde dice my_index = (my_index + 1) % 8;que el compilador, incluso con las optimizaciones establecidas en su nivel más bajo, probablemente lo convertirá en el lenguaje de máquina equivalente a my_index = (my_index + 1) & 0x0007;. En otras palabras, no se dividirá (muy costoso), ni siquiera se bifurcará (menos costoso), pero se enmascarará (menos costoso en la mayoría de los procesadores actuales ). Pero esto solo sucederá si el módulo es una potencia de dos.

Puede asegurarse de que simplemente use my_index = (my_index + 1) & 0x0007;, a costa de la comprensión del código y, por lo tanto, de la capacidad de mantenimiento del código. Coméntalo bien si vas por ese camino.

Entonces, en su caso específico , siempre que eso % 8no cambie, o solo cambie a % Ndonde Nsiempre está 2 norte y el compilador sabe que la velocidad no cambiará. Pero si usted o alguien más aparece más tarde y lo cambia a my_index = (my_index + 1) % 17;(o cualquier otro que no sea potencia de 2), de repente su código tendrá una operación de división y consumirá más energía. En ese caso, usar la declaración condicional será menos costoso.

(En C/C++, se asegura de que el compilador conozca el valor de una constante con anticipación mediante el uso de una # defineinstrucción o (dependiendo del optimizador) declarándola const unsigned into (más fuerte, si es C++) declarándola constexpr int. Otros lenguajes compilados ( es decir, Rust) tienen sus propias formas de hacer que esto suceda).

Nota: no me sorprendería que un buen compilador de optimización no convirtiera la construcción 'si' en una máscara, pero no me sorprendería si no lo hiciera. Lo mismo, un compilador de optimización realmente bueno podría ver my_index = (my_index + 1) % 17;e inferir la construcción condicional. No creo que contaría con eso sin mirar el ensamblado, y no creo que confiaría en él al 100%; podría usarlo , pero pondría un comentario en el código sobre cruzar mi dedos y esperando que el compilador funcione bien.

A menos que esté absolutamente contra la pared por el consumo de energía, también debe pensar en la legibilidad y la fragilidad del código. Alguien vendrá más tarde y necesitará entender ese código, y lo apreciará si no es un campo minado lleno de oportunidades para arruinarlo. Ese alguien puede ser el futuro-tú, ¡así que sé amable!

Si my_indexes un tipo firmado, %8será más caro que &7, a menos que el compilador logre probar que el valor no es negativo en ese punto. (Porque el asm tendrá que producir un resto negativo para negativo my_index).
No estoy realmente seguro de que el %8formulario sea más "legible" que el &7formulario y, en cualquier caso, if (my_index == 8)podría decirse que es más autodocumentado. Un problema con el uso de & es que, si tuviera una macro, my_index = (my_index + 1) & (INDEX_MAX -1)podría suponer que INDEX_MAXdebe ser una potencia de dos, pero es posible que alguien más adelante no comparta esa suposición, luego lo configuran en 10 y rompen su código.
Otra cosa a tener en cuenta: ARM puede potencialmente hacer código condicional sin una bifurcación, pero no si el decodificador de instrucciones está limitado al conjunto de instrucciones del pulgar (y creo que esos números de parte sí tienen esa limitación)
@Rodney: Mi preferencia es tener dos definiciones adyacentes, por ejemplo , SER_RXQUEUE_SIZEy SER_RXQUEUE_MASK, con la última definida justo debajo de la primera, como (SER_RXQUEUE_SIZE-1). Entonces se podría usar a #ifpara chirriar si (SER_RXQUEUE_SIZE & SER_RX_MASK)no es cero.
@Rodney: Cortex-M4 no tiene Thumb completo, pero admite it/ ite(para predicar hasta 4 instrucciones posteriores). godbolt.org/z/shqaGW6d5 . Tiene razón en que M0 no lo admite, o al menos los compiladores eligen no usarlo para esa prueba. IIRC, el predicado se evalúa por separado para cada instrucción predicada, por lo que las instrucciones de establecimiento de banderas, como cmpdentro de un itbloque, funcionan como en el modo ARM, por lo que itno necesariamente se puede manejar como un salto sobre la parte falsa. Sin embargo , existe una restricción para que una rama predicada sea la última insn en un itbloque.
@PeterCordes buen punto sobre firmado y el %8. Puede que no siempre sea más costoso, porque el escritor del compilador tiene cierto margen de maniobra en la forma en que lo implementa para que pueda hacerlo de modo que (a % 8) == (a & 0x0007)siempre sea cierto. Por lo general, será lo que sea que haga el procesador debajo del capó.
@Rodney sí. Supongo que si está tratando de guardar cada ergio recortando los tictacs del reloj, puede estar dispuesto a hacer que el código sea un poco menos legible. Toco eso en mi respuesta, pero básicamente, si necesita optimizar tanto, puede valer la pena tener dos líneas de comentarios y una línea de código realmente extraña, solo para que funcione correctamente.
@TimWescott: en contextos en los que está bifurcando o booleanizando en 0/distinto de cero, int % 8puede optimizarse a solo una instrucción AND o TEST / TST, a menos que lo derrote con a%2 == 1(y los compiladores reales hacen esto) godbolt.org/z/xPnjPWq7f ). El ejemplo en la pregunta no era uno de esos casos. En ISO C99 y versiones posteriores, a%8está completamente definido como un resto, no módulo, para ir con /una definición completa como truncamiento hacia cero, no como Python. (Y sí, esto es lo que hacen todas las CPU convencionales modernas con división HW debajo del capó) IIRC, C89 permitió la elección que sugiere.
@PeterCordes Argh. ¡Estoy un poquito atrasado en mi lectura! No quise decir que uno realmente estaría haciendo esa prueba, solo tratando de ilustrar mi punto (aparentemente obsoleto).
…or only ever changes to % N where N is always 2^n… y el compilador lo sabe (es una constante de tiempo de compilación, no una variable pasada a una función, por ejemplo)
@jcaron gracias. He incorporado eso en mi respuesta.

En primer lugar, en caso de que no sea obvio: un mayor tiempo de ejecución significa un mayor consumo de energía. Aunque si lo que más le interesa es reducir el consumo de energía, mire el reloj del sistema antes que nada.

¿Por qué la operación de módulo consume más energía?

No lo hace en un compilador moderno. La división y el módulo son operaciones pesadas de CPU para la mayoría de los núcleos, pero el código C que generaba divinstrucciones reales, etc., ocurría principalmente hasta hace unos 15 o 20 años. Los compiladores modernos elegirán el mejor código cuando habilite las optimizaciones y evitarán la división cuando se pueda evitar. Además, la división es un problema mayor en cuanto al rendimiento en MCU de gama baja como 8 y 16 amargos.

Sin embargo, debe mencionarse que es una práctica algo común en los sistemas integrados ejecutar con todas las optimizaciones deshabilitadas. Principalmente porque los optimizadores de compiladores de varios compiladores integrados de calidad mediocre se ganaron legítimamente una mala reputación de tener errores, allá por los 90 y principios de los 2000.

Si ejecuta con las optimizaciones deshabilitadas, entonces, por supuesto, está solo y tiene que realizar todas las optimizaciones manualmente, lo que definitivamente no es una práctica recomendada a menos que tenga un conocimiento profundo sobre C y la CPU de destino.


Vamos a desensamblar sus ejemplos de código particulares en gcc-arm-none-eabi, con -O3. Hice estos ejemplos independientes:

void func1 (void)
{
  static unsigned int my_index;
  while(1) 
  { 
    my_index++;
    if (my_index >= 8) my_index = 0;

    volatile unsigned int out = my_index;
  }
}

void func2 (void)
{
  static unsigned int my_index;
  while(1) 
  { 
    my_index = (my_index + 1) % 8;
    volatile unsigned int out = my_index;
  }
}

Los volátiles son necesarios como efecto secundario para impedir que el optimizador elimine el código por completo. Ahora, al desmontar esto usando Godbolt https://godbolt.org/z/bM5M5v38h , obtenemos un código de máquina casi idéntico. Ninguna división a la vista. La versión con adición en realidad está funcionando un poco peor debido a la cmpinstrucción (rama).


Pensé que eliminar declaraciones condicionales podría funcionar mejor

Sí, en general, y en su caso realmente lo hace, aunque es una microoptimización. Cortex M en general no tiene predicción de rama avanzada ni memoria caché. En un M0, no vale la pena el dolor de cabeza siquiera considerarlo. Creo que algunos STM32x4 tienen soporte de hardware para una forma simple de predicación de bifurcación. M7 de gama alta, etc. tendrá caché y luego evitar las ramas es más importante.


En general:

Debe esforzarse por escribir código lo más legible posible. Luego optimice cuando haya cuellos de botella de rendimiento reales en su código. Las optimizaciones manuales son un trabajo altamente calificado y requieren mucha experiencia.

En este caso particular, diría que la versión de adición/contador es más legible, por lo que la usaría independientemente de unos pocos tics de CPU más o menos.


En cuanto al blog que vinculó, el autor no es un novato completo y tiene algunos puntos buenos, pero se mencionan algunas cosas extrañas e incluso equivocadas. Déjame comentar sobre esa lista de viñetas de la que obtuviste el comentario del módulo:

  • Utilice la clase "Static Const" tanto como sea posible para evitar la copia en tiempo de ejecución de matrices, estructuras, etc. que consume energía.

    No tengo idea de lo que se supone que significa una clase de "const. estática". C distingue notablemente entre mayúsculas y minúsculas y no tiene una palabra clave de clase. Supongo que el autor no conoce la terminología adecuada de C y en realidad quiere decir algo como: Use staticespecificadores de clase de almacenamiento y corrección constante siempre que sea posible. Si eso es lo que querían decir, es un buen consejo general.

  • Utilice punteros. Probablemente son la parte más difícil de entender del lenguaje C para los principiantes, pero son los mejores para acceder a estructuras y uniones de manera eficiente.

    Es como decirle al trabajador de la construcción que use concreto... es obligatorio, no una opción. Los punteros son un bloque de construcción fundamental de C.

  • ¡Evita Módulo!

    No es realmente un buen consejo como se demostró anteriormente.

  • Variables locales sobre variables globales cuando sea posible. Las variables locales están contenidas en la CPU mientras que las variables globales se almacenan en la RAM, la CPU accede a las variables locales más rápido.

    Generalmente correcto, aunque las variables de alcance de archivo ("globales") también pueden almacenarse temporalmente en registros. La principal razón para evitar variables verdaderamente globales (enlace externo) es el diseño del programa, no el rendimiento.

    Además, la diferencia entre el registro y el acceso a la RAM no es tan grande en la mayoría de las MCU, este comentario se aplica principalmente a las CPU de gama alta como x86, Cortex A, Power PC, etc. Al optimizar manualmente el acceso a la memoria para las MCU de gama media como Cortex M, debe más bien considere flash vs RAM, ya que flash a menudo tiene estados de espera.

    Sin embargo, reducir el alcance de las variables siempre es bueno para la legibilidad, para minimizar los errores y reducir el desorden del espacio de nombres.

  • Los tipos de datos sin firmar son su mejor amigo siempre que sea posible.

    Cierto, pero no por el rendimiento, sino por las conversiones implícitas y el comportamiento mal definido de los operandos con signo/negativos cuando se usan en operaciones bit a bit.

  • Adopte la "cuenta regresiva" para los bucles cuando sea posible.

    Ok, este es un consejo de dinosaurio aún peor que el del módulo. La razón de esto es muy conocida, que comparar con cero es más rápido que comparar con un valor. ¡Pero los compiladores han podido hacer esa optimización durante mucho tiempo! No escriba bucles de conteo regresivo, eso es simplemente ofuscación por nada ganado. Este fue un consejo válido alrededor del año 1993, no en el año 2022.

  • En lugar de campos de bits para enteros sin signo, utilice máscaras de bits.

    Buen consejo, pero tampoco relacionado con el rendimiento, sino con la portabilidad y el comportamiento mal definido.

En general, la calidad de la publicación del blog es diversa: los consejos muy sólidos se mezclan con los malos. Dejaría de leer ese blog. Tenga en cuenta con qué frecuencia en esta respuesta tengo que retroceder 20-30 años en el tiempo al comentarla.

El "comportamiento mal definido de los operandos con signo" es otra reliquia del siglo anterior. C99 arregló eso. Estoy de acuerdo en que el mundo incrustado sufre mucho por las historias obsoletas. Hay una buena razón por la que GCC/ARM se ha convertido en un elemento básico del desarrollo integrado, pero eso ha elevado el nivel de la competencia.
Re “ Static Const ”: En C (pero no en C++) una constvariable global tiene un enlace externo por defecto. Por lo tanto, a menos que realice una optimización del tiempo de enlace, se asignará en la RAM. Un global static consttiene enlace interno, y el compilador tiene la oportunidad de optimizar su almacenamiento.
@MSalters No, con eso me refiero a las debilidades en el lenguaje C en sí. Desplazar a la izquierda un número con signo en el bit de signo da un comportamiento indefinido en C17. Desplazar a la derecha un número negativo proporciona un comportamiento definido por la implementación. El complemento bit a bit ~de un número entero pequeño puede convertir números sin signo en signos y negativos. El desbordamiento aritmético con signo es un comportamiento indefinido en C pero bien definido en la CPU ISA. Etcétera.
@EdgarBonet Sí, no, tal vez :) He visto a los enlazadores hacer todo tipo de asignaciones cuando se enfrentan a algo que solo obtuvo constun calificador en el alcance del archivo. Podría ser RAM, podría ser ROM, o podría ser algún enlace de rareza de arquitectura de Harvard en caso de que se aplique. Agregar statices una buena práctica con seguridad, pero no hay garantías. Siempre verifique el archivo del mapa para estar seguro.
En el olor del viejo mundo, si hiciera el mod 8 enmascarando e incluyera un comentario "Esto calcula el número de módulo 8", mi jefe insistiría en que se elimine el comentario. ¡De verdad, así fue!
@ richard1941 Bueno, eso es raro. Siempre dejaría un comentario como ese, incluso cuando hago algo mucho más obvio como x >>= 1; // divide by 2.