ponemos-a-prueba-como-de-listo-es-el-compilador-de-arduino

Ponemos a prueba como de listo es el compilador de Arduino

Hoy vamos a poner a prueba el compilador de Arduino, haciendo una serie de pruebas facilonas para ver cómo de inteligente es a la hora de hacer optimizaciones en nuestro código.

Con frecuencia en el chat os digo que nos os obsesionéis con la velocidad del código y siempre prioricéis limpieza frente a velocidad. Bueno, pues hoy vamos a ver justificar un poco este discurso.

En realidad, al hablar del “compilador de Arduino”, estamos usando un compilador de C++ genérico, habitualmente Gcc. Entonces ¿Cómo de inteligente es un compilador de C++ como GCC?

Pues, a estas alturas de la película (o como se suele decir, en el “estado del arte”) muy muy muy inteligente. Mucho más que tu, que yo, e incluso o que tu y yo juntos.

No es para deprimirse. Simplemente, los compiladores actuales son programas muy avanzados, desarrollados durante muchos años, con las aportaciones y el trabajo de grandes genios. Así que es lógico que hagan un trabajo excelente optimizando códigos.

Pero por si no nos lo creemos, vamos a hacer unos pocos ejemplos básicos. Para las pruebas vamos a usar un ESP32, pero sería lo mismo haciéndolo con otro tipo de procesador. Además, los tiempos en concretos que vamos a obtener nos dan un poco igual. Lo que queremos ver son las tendencias, y ciertas optimizaciones que vamos a ver de forma muy clara.

Bucle con incremento

Vamos a empezar por un ejemplo base, ejecutado en un ESP32.

void printResults(unsigned long long counter, unsigned long long rst, unsigned long long microseconds)
{
  Serial.print("benchmark ");
  Serial.print(counter);
  Serial.print(": ");
  Serial.print(rst);
  Serial.print(" ");
  Serial.print(microseconds);
  Serial.println("us");
}

void benchmark()
{
  auto start = micros();

  unsigned long counter = 1000000;
  unsigned long sum = 0;
  for (size_t i = 0; i < counter; i++)
  {
    sum ++;
  }
  
  auto finish = micros();
  auto microseconds = finish - start;
  printResults();
}

void setup()
{
  Serial.begin(115200);
}

void loop()
{
  delay(5000);
  benchmark();
}

Aquí básicamente tenemos,

  • Una función ‘printResults()’ que muestra los datos por la pantalla
  • Otra función de benchmark, que es la que realiza los cálculos y las pruebas que queremos realizar
  • Por último, una función muy simple de setup que inicializan el puerto, y un loop muy sencillo que lanza el benchmark

En este primer caso base, simplemente estamos entrando en un bucle 1.000.000 (1E6) de veces e incrementando una variable, para ver lo rápido que se ejecuta. Si ejecutamos esto veremos algo similar al segundo resultado,

benchmark 1000000: 1000000 1us

Y podemos pensar, mmmm… qué rápido es nuestro nuestro procesador realiza un bucle de un millón de elementos en un microsegundo. Así que vamos a variar la cantidad de veces que ejecutamos el bucle. Los resultados son los siguientes,

10^NResultus
310001
4100001
51000001
610000001
7100000001
81000000001
910000000001

Bueno, aquí ya vemos que algo raro está pasando. El test se está ejecutando siempre en un tiempo de un microsegundo independientemente de las cantidad de veces que entramos en el bucle.

Por otro lado estamos hablando de un procesador de 240 Mhz. Aún teniendo en cuenta que cada operación cuesta más de un ciclo de reloj, es lógico esperar que entre 1E8 y 1E9 estemos en el rango de un segundo. Y estamos en un rango de microsegundos.

¿Qué es lo que está pasando? Que el compilador es lo suficientemente listo para saber resolver la operación, sin tener que realizar realmente el bucle. Es decir, al compilar el código, el binario generado no tiene la parte del bucle. El compilador la ha sustituido por el resultado final de la operación.

Bucle con literales y constantes

Vamos a ver que es lo que pasa si en lugar de simplemente incrementar una variable, sumamos un literal.

unsigned long counter = 1000000;
unsigned long sum = 0;
for (size_t i = 0; i < counter; i++)
{
  sum += 3;
}

Que si ejecutamos el test obtemos,

benchmark 1000000: 3000000 1us
benchmark 1000000000: 3000000 1us

Es decir, el compilador también es lo suficientemente listo para saber resolver el bucle sin tener que realizarlo.

¿Y si lo cambiamos por una variable constante?

const int value = 5;
unsigned long counter = 1000000;
unsigned long sum = 0;
for (size_t i = 0; i < counter; i++)
{
  sum += value;
}

También sabe resolverlo sin ejecutar

benchmark 1000000: 50000000 1us

¿Y si no fuera una variable, y no fuera constante?

int value = 10;
unsigned long counter = 1000000;
unsigned long sum = 0;
for (size_t i = 0; i < counter; i++)
{
  sum += value;
}

Efectivamente, el compilador es lo suficiente listo para saber que esa variable no está cambiando en el código, incluso aunque no la hemos marcado con ‘const’

benchmark 1000000: 50000000 1us

Vaya, que listo es el compilador ¿Y si el valor lo devolvemos desde una función?

int givemeNumber()
{
  return 3;
}

void benchmark()
{
  auto start = micros();
  
  unsigned long counter = 1000000;
  unsigned long sum = 0;
  for (size_t i = 0; i < counter; i++)
  {
    sum += givemeNumber();
  }
  
  auto finish = micros();
  auto microseconds = finish - start;
  printResults(counter, 0, microseconds);
}

Efectivamente, el compilador no solo ha sido capaz de calcular el resultado correcto sin llamar a la función, si no que en el código binario ¡desaparecerán por completo el bucle y la función!

benchmark 1000000: 3000000 1us

Bucle con variable

Bueno, está claro que este compilador es muy listo. Vamos a intentar ponerla la vida más difícil. En lugar de sumar un número que no varia, vamos a sumar un número que sí varia. Por ejemplo, vamos a sumar el propio ‘i’ del bucle,

void benchmark()
{
  unsigned long counter = 1000000;
  unsigned long sum = 0;
  for (size_t i = 0; i < counter; i++)
  {    
    sum += i;
  }
}

Lanzamos nuestro caso base para 1E6 de elementos y obtenemos.

6  1783293664  8393

Vale, ahora sí ha entrado en el bucle. Lo vemos fácilmente porque ya no es 1 microsegundo, si no 8393ms. Repetimos que no vamos a valorar la velocidad del ESP32, no es importante. Lo importante es que esta vez el compilador no ha sido capaz de calcular la variable sin entrar en el bucle.

Un poco decepcionante. Si el compilador fuera un poco más listo, sabría que el resultado es N*(N+1)/2. Pero supongo que esto s demasiado esperar de un pobre programita (spoiler: ya veremos).

Sigamos. Igual que antes, vamos a cambiar el número de veces que entramos en el bucle. Los resultados que obtenemos son los siguientes.

10^NResultus
34995001
4499950001
5704982704835
617832936648393
7-201426003283946
8887459712839515
930516579848395178

Aquí vemos que en el caso de 1E5 el rango es de 835us, y se incrementa de forma casi perfectamente lineal con el número de veces del bucle. Es decir, que el programa sí esta entrando en el bucle en estos casos.

Pero, para los casos 1E3 y 1E4 el tiempo vuelve a ser de 1us. Es decir, el compilador ha sido capaz de calcular el resultado sin necesidad de realizar el bucle. ¿Es posible que el compilador si conozca la fórmula N*(N+1)/2? Y en ese caso, ¿Por qué sabe aplicarla en 1E3 y 1E4 pero no en 1E5 en adelante?

Bueno, para eso tenemos que fijarnos en que el resultado calculado ha desbordado. Es decir, ha excedido el tamaño máximo de la variable sum, que es un unsigned long. En el caso de un ESP32 es de 4bytes.

En la siguiente tabla pongo los resultados del cálculo que está realizando, indicando cuando desbordaría los distintos tipos de variables.

int / longuint / ulonglong longulong long
10^NResult2,15E+094,29E+092,31E+184,61E+18
14,50000000000E+01
24,95000000000E+03
34,99500000000E+05
44,99950000000E+07
54,99995000000E+09
64,99999500000E+11
74,99999950000E+13
84,99999995000E+15
94,99999999500E+17
104,99999999950E+19

Vemos que, precisamente, para 1E4 el resultado aún no ha desbordado. Pero, para 1E5 en adelante, sí desborda. Es decir, el compilador sabe calcular la operación sin entrar en el bucle mientras la variable resultado no desborda.

Si la variable desborda, al compilador no le queda otra que realizar el bucle para ejecutar el cálculo. Y con razón, porque este cálculo ya no es tan trivial como la ecuación que hemos citado. Por este motivo vemos este salto de entre 1E4 y 1E5 iteraciones.

¿Y si pasamos a una variable de tipo long long de 8 bytes? Aquí tenemos los resultados,

10^NResultus
34995001
4499950001
549999500007554
649999950000075554
749999995000000755657
849999999500000007565389
949999999950000000076533470

Observamos que los tiempos son mucho mayores. Lógico, porque las operaciones de 64bits le van a costar mucho más esfuerzo al ESP32, que es un procesador de 32bits. Pero ya hemos dicho que no nos importa mucho el valor concreto.

Lo que sí es importante es que los resultados ya son siempre correctos, y en ningún caso desbordar la variable de resultado. Y vemos que los tiempos se incrementan linealmente… a partir de 1E5. Seguimos teniendo el mismo salto en 1E3 y 1E4, en el que el compilador está siendo capaz de eliminar el bucle.

¿Por que para < 1E5, si la variable ahora no está desbordando? Pues entiendo que se debe a que el procesador es de 32bits por lo cuál, aunque ahora estemos usando variables de 64bits, los cálculos necesarios impiden que pueda optimizar porque requieren un numero mayor de operaciones que el compilador no sabe eliminar.

Bucle con suma aleatorio

Vamos a intentar ponerle la vida más difícil al compilador. ¿Cómo podemos hacer para que el compilador no sea capaz de “cargarse” el bucle en ningún caso? ¿Que entre siempre, independientemente de que sea 1, 10 veces o 1.000.000 las que se ejecuta el bucle?

Pues por ejemplo, podemos sumar una variable aleatoria. Con esto garantizamos que “por fuerza” el programa va a tener que entrar en el bucle para calcularlo, no va a poder hacer ninguna optimización previa.

unsigned long counter = 1000000;
unsigned long sum = 0;
for (size_t i = 0; i < counter; i++)
{
  sum += random(10);
}

En realidad, no tendría porque ser un numero aleatorio. Cualquier variable no determinista, como por ejemplo una entrada digital, o un valor recibido por una comunicación valdría igualmente.

Lo ejecutamos y obtenemos,

10^NResultus
1aleatorio8
2aleatorio68
3aleatorio678
4aleatorio6809
5aleatorio68143
6aleatorio681428
7aleatorio6814308
8aleatorio68143113

Lógicamente, los tiempos son muy superiores debido al sobre coste de generación del número aleatorio. Pero esto nos da igual, lo que nos interesa es que no ha sido capaz de simplificar nada. Así, vemos que desde 1E1 a 1E8 el tiempo aumenta linealmente, y aproximadamente es 10 veces superior al anterior en cada nivel.

Perfecto ¡Acabamos de amargar la vida a un compilador! (estaréis orgullosos…) Y, de paso, hemos demostrado que todo lo que suponíamos y hemos explicado anteriormente, era efectivamente cierto.

No usamos el resultado

Vamos a ver otro ejemplo. Volvamos al caso en el que sumamos ‘i’ 1E6 veces. Pero esta vez, a la función ‘debug’ no le vamos a pasar el resultado. Es decir, en este caso no usamos la variable ‘sum’ más que en el bucle, pero no después.

void benchmark()
{
  auto start = micros();
  
  unsigned long counter = 1000000;
  unsigned long sum = 0;
  for (size_t i = 0; i < counter; i++)
  {
    sum += i;
  }
  
  auto finish = micros();
  auto microseconds = finish - start;
  printResults(counter, 0, microseconds);  // no estamos usando sum
}

Si lo ejecutamos, vemos que el tiempo pasa de 8400us a 0

benchmark 1000000: 0 0us

Es decir, el compilador ha sido capaz de detectar que estas realizando un cálculo que no estas usando en ningún lado. En consecuencia, elimina por completo todo el código asociado. Efectivamente, se ha cargado el bucle y todas las variables que intervienen en el.

¿Y si en vez de sumar ‘i’, volvemos a sumar random()?,

void benchmark()
{
  auto start = micros();
  
  unsigned long counter = 1000000;
  unsigned long sum = 0;
  for (size_t i = 0; i < counter; i++)
  {
    sum += random(10);
  }
  
  auto finish = micros();
  auto microseconds = finish - start;
  printResults(counter, 0, microseconds);
}

Aquí no, no ha sido capaz de eliminar el bucle incluso aunque no se está usando el resultado en ningún lado posterior al cálculo.

benchmark 1000000: 0 681200us

El motivo es que la función random() es suficientemente compleja para que el compilador no sepa que hace. Nosotros sabemos que básicamente devuelve un número. Pero el compilador de determinar que es lo que hace esa función. Así que se cura en salud, y no la elimina.

Bucle decremental

Ya que estamos, vamos a probar otro caso. Es frecuente entre los programadores más veteranos que los bucles decrementales (i—) son más rápidos que los bucles incrementales (i++)

Esto se basa en que, según la teoría, la comparación != 0 es más rápida que la comparación < o <=, por tanto deberíamos arañar unos valiosos microsegundos. Ya que estamos, vamos a probarlo.

void benchmark()
{
  auto counter = 1000000;
  auto sum = 0;
  for (size_t i = counter - 1; i != 0; i--)
  {
    sum += i;
  }
}

Y, como era de esperar, el resultado es exactamente el mismo que ejecutado de forma incremental.

benchmark 1000000: 1783293664 8393us

Probablemente, esto era así hace 50 años, con procesadores y compiladores muy antiguos. Es más, si realmente hubiera alguna diferencia de rendimiento, el compilador es lo suficientemente inteligente para sustituir esto por ti.

Es más, como os digo muchas veces, a día de hoy conviene evitar este tipo de “triquiñuelas”. Puede que intentando optimizar el código, consigas que el compilador no sea capaz de hacer alguna de sus optimizaciones, e incluso empeores el rendimiento.

Asi que a la pregunta ¿cómo de inteligente es el compilador de C++ de Arduino? la respuesta es, muy inteligente. Escribe tu código de forma natural, limpia y bien estructurada, y confía en que el compilador sabe hacer su trabajo.

Espero que os haya gustado la entrada ¡nos vemos en la siguiente!