Actualmente buena parte de las opiniones acerca de C++ se basan en el viejo estándar C++98, sin embargo, el cambio en C++11 fue dramático y muy positivo, y ya han pasado C++14, C++17 y se viene trabajando en C++20. De modo que quien piense que el lenguaje C++ está tirado en el olvido o ya no sirve en esta época del Internet ha estado simplemente mirando hacia otras direcciones que le resultan más atractivas o prioritarias para su oficio y por lo tanto desinformado.

Quiero darle un enfoque práctico a estos blogs, es decir, hablar de cosas útiles en lugar de explicar las características del lenguaje o las facilidades de la Librería Estandar de C++, la STL. Ya tenemos muchos recursos de eso en la web.

Para poder iniciar la larga empresa de enseñar a programar, más que a enseñar C++, hay que comenzar por algoritmos y estructuras de datos, para poder medir la eficiencia de ejecución de un programa es necesario ser capaz de medir su tiempo de ejecución y al finalizar este blog espero que los lectores entiendan cómo cronometrar la ejecución de sus programas en C++.

En C y C++ esta historia es interesante, porque los avances en la tecnología de CPUs ha impactado las librerías que se usan en estos lenguajes para medir el tiempo de ejecución de los programas . Mi primera PC tenía un microprocesador Intel 8088 cuyo reloj daba la increíble velocidad de 5 Mega Hertz (MHz), es decir 5 millones de ciclos por segundo, y si pulsaba el botón de turbo subía a 8MHz. Para entonces, si el reloj del CPU oscilaba a millones de ciclos por segundo, podíamos medir cualquier proceso con una precisión de los microsegundos.

No sabemos aun exáctamente qué es, pero eso no impide medirlo

Las librerías se diseñaron tomando esta precisión como la máxima y allí ocurrió un error: no pensar en el futuro al diseñar un API de programación para medir el tiempo (¿contradictorio no?). Pasaron pocos años y los CPUs llegaron a oscilar mil veces más rápido que entonces, vamos hoy día por los GigaHertz (mil millones de ciclos por segundo), por lo tanto somos capaces de medir los procesos con una precisión de los nanosegundos. Hubo entonces que rediseñar la librería para poder capturar estos valores.

Ahora, tenemos inminentes avances en el campo de la ingeniería de materiales que prometen dar un nuevo salto en la frecuencia de los relojes a los 500GHz y más (lean acerca del grafeno – láminas de carbón de un átomo de espesor, un material para el futuro), así que al llegar al TeraHertz (un billón de ciclos por segundo) podremos medir con más precisión aun (picosegundos), me pregunto cuál será la medida de tiempo más pequeña que tiene sentido medir (físicamente hablando). Les dejo un enlace también para entender las escalas de mili, micro, nano, pico, etc.

Viendo esto como algo inexorable, los diseñadores de la Librería Entandar de C++ resolvieron el problema ahora y para siempre con la librería <chrono>, sin embargo, el precio por este diseño tan perdurable es la complejidad. No pretendo explicarlo en detalle, los aburriría, lo que quiero es darles un enfoque práctico, enseñarlo a usar de modo que les sea útil en pocos minutos, por lo menos por los siguientes 5 años.

Pueden probar los snipets de código que les dejo acá sin tener que instalar nada, puesto que hoy día contamos ya con compiladores gratis online de casi cualquier lenguaje de programación que queramos estudiar. Les dejo uno aquí para los curiosos (recuerden seleccionar el compilador de C++14 arriba a la derecha o C++17 si está disponible).

Hablaremos entonces de:

  • ¿Cómo representar duraciones?
  • ¿Cómo medir intervalos de tiempo?

¿Cómo representar duraciones?

La librería chrono trae pre-configurados tipos de datos para las duraciones, pero no se limita a ellos, veamos la declaración de unas variables:

#include <iostream>
#include <chrono>

using namespace std;

// Desde C++11: libreria chrono
chrono::hours hrs (12);
chrono::minutes mins (30);
chrono::seconds secs (30);
chrono::milliseconds ml (350);
chrono::microseconds mcr (250);
chrono::nanoseconds nn (765);

int main ()
{
...

Esto se ve muy bien, listo para usar, pero aún mejor quedó con C++14, que incorporó literales definidos por el usuario y las duraciones son súper explícitas en el código (fíjense en los sufijos de los números en el código):

int main () {
   cout << "Desde C++14: literales definidos por el usuario:";
   hrs = 23h;
   mins = 59min;
   secs = 23s;
   ml = 50ms;
   mcr = 70us;
   nn = 80ns;
...

Las constantes numéricas tienen sufijos que permiten incorporar más claridad y expresividad al código. Se puede conocer siempre las unidades físicas con las que estamos trabajando.

Ok, hasta acá todo bien, pero para seguir necesitamos explicar cómo la librería chrono define lo que es una duración, de lo contrario cuando hagamos aritmética de duraciones (esto es sumas, restas, etc) y las queramos imprimir por consola sentirían algo así como entrar en la dimensión desconocida.

  • Para definir una duración, el primer factor que se especifica es la unidad de medida que se usará respecto del segundo (con una fracción), esto es el tamaño del “tick” de tiempo, así:
    • Una hora son 3600/1 segundos
    • Un minuto son 60/1 segundos
    • Un milisegundo son 1/1.000 segundos
    • Un microsegundo son 1/1.000.000 segundos
  • El segundo factor es la cantidad de ticks
  • La multiplicación de los ticks por la unidad de medida en uso indica la duración.

La declaración de una duración es algo así (como todo en la STL, el tipo duración es un template):

// typedef chrono::duration<TicksType, SecsFraction> nombreDeTipo;
// ejemplo:
typedef duration<int64_t, ratio<3600>> hours;
typedef duration<int64_t, ratio<60>> minutes;
typedef duration<int64_t, ratio<1, 1000>> milliseconds;
typedef duration<int64_t, ratio<1, 1000000>> microseconds;

No se angustien, parece una fumada lo sé… vamos por partes:

  • El primer parámetro “int64_t”, indica el tipo de dato que usaremos para representar los ticks de tiempo, en este caso usamos enteros (si, pueden usarse float o double también, pero mantengamos las cosas simples).
  • ratio es un template que existe desde C++11 para expresar números racionales (fracciones) en tiempo de compilación (cuando el denominador es 1, se omite). Con este tipo de dato expresamos la dimensión del tick de tiempo respecto del segundo.

Pongamos varios ejemplos:

  • 5 horas son: 5 * (3600/1) segundos, cada tick (o el tamaño de la unidad que estamos contando) son 3.600 segundos.
  • 40 minutos son: 40 * (60/1) segundos.
  • 333 milisegundos son: 333 * (1/1.000) segundos.

Si entendieron esto entonces podemos entender una función en C++ para imprimir por consola una duración (cabe notar que esta función no viene con la librería):

template <typename Ratio>
ostream& operator << (ostream& ostr, 
                      const chrono::duration<int64_t, Ratio>& dur) {
  ostr << '[' << dur.count() << " * " 
       << Ratio::num << "/" 
       << Ratio::den << "]";
  return s;
}

Para los que no son tan conocedores de C++, no se asusten, los templates son la parte más sofisticada (avanzada) de cualquier lenguaje de programación “serio” y aquí hay además la sobrecarga del operador “<<“, lo que usamos en C++ para escribir a cónsola (cout), expliquemos un poco entonces:

  • El nombre de la función es “operator <<“
  • Devuelve una referencia a un ostream o flujo de datos de salida (cout representa la cónsola, la cual es un flujo de datos de salida).
  • El primer parámetro es una referencia a un ostream
  • El segundo parámetro es una referencia a cualquier duración que use enteros para contar los ticks sin importar el tamaño del tick respecto del segundo.

Con este “function template” podemos entonces imprimir cualquier duración que use enteros para contar los ticks, donde:

  • dur.count(): obtiene el número de ticks.
  • Ratio::num: obtiene el numerador de la fracción que usamos para expresar nuestra unidad de tiempo respecto del segundo.
  • Ratio::den: obtiene el denominador de la fracción.
  • La multiplicación es entonces la duración misma.

De este modo el programa:

#include <iostream>
#include <chrono>

using namespace std;

template < typename Ratio >
ostream & operator << (ostream & ostr, 
                       const chrono::duration < int64_t, Ratio >& dur) {
  ostr << '[' << dur.count () << " * " 
       << Ratio::num << '/' 
       << Ratio::den << ']';
  return ostr;
}

int main () {
  chrono::hours hrs (12);
  chrono::minutes mins (30);
  chrono::seconds secs (30);
  chrono::milliseconds ml (350);
  chrono::microseconds mcr (250);
  chrono::nanoseconds nn (765);
  cout << hrs << " + " << mins << " + " << secs << " + "
       << ml << " + " << mcr << " + " << nn << endl;
  return 0;
}

Produce la siguiente salida:

[12 * 3600/1]  + [30 * 60/1]  + [30 * 1/1]  + [350 * 1/1000]  + [250 * 1/1000000]  + [765 * 1/1000000000]

Esto es todo lo que necesitamos saber acerca de las duraciones en C++.

¿Cómo medir intervalos de tiempo?

Para medir el tiempo de ejecución de nuestros programas sólo necesitamos:

  • Obtener el momento (punto de tiempo) en el que inicia un programa o rutina.
  • Obtener el punto de tiempo del final
  • Restar el final menos el inicio, para así obtener una duración
  • Imprimir la duración expresada de forma amigable (horas:minutos:segundos.milisegs.microseg.nanosegs)

En el siguiente código sólo pausamos el thread principal medio segundo para simular la demora de alguna rutina siendo llamada:

#include <iostream>
#include <chrono>
#include <thread>

using namespace std;

template < typename Ratio >
ostream & operator << (ostream & ostr, 
                       const chrono::duration < int64_t, Ratio >& dur) {
  ostr << '[' << dur.count () << " * " 
       << Ratio::num << '/' 
       << Ratio::den << ']';
  return ostr;
}

int main() {
    auto start = chrono::steady_clock::now();
    chrono::milliseconds sleepDuration { 500ms };
    this_thread::sleep_for(sleepDuration); // dormir por medio segundo
    auto end = chrono::steady_clock::now();
    auto elapsedTime = end - start;
    cout << "Sleep time: " << elapsedTime << endl;
}

Hacemos uso del reloj “steady_clock” de la librería chrono el cual no es impactado por los ajustes que puedan hacerse al reloj del sistema. En chrono existen otros dos relojes, pero éste es el que debemos usar para cronometrar. La salida de este programa es la siguiente:

Sleep time: [500109351 * 1/1000000000]

No son exactamente 500 milisegundos, se excedió (aunque en las pruebas que hice en Windows, el thread se detenía por menos de medio segundo, cercano, pero siempre era un lapso de tiempo inferior), ¿por qué?, un tema importante a tener en cuenta con la función “this_thread::sleep_for”, es que en los sistemas operativos comunes, a los cuales tenemos acceso la mayoría de los mortales, “this_thread::sleep_for” usa las duraciones como una aproximación, para pausar el thread cumpliendo los 500 milisegundos con mucha mayor precisión necesitaríamos un sistema operativo de tiempo real.

¿Cómo imprimir las duraciones de forma amigable?

Si imprimimos la duración tal y como queda luego de calcularla, vamos a imprimir un valor en nanosegundos, que son números tan grandes que no son fáciles de entender para el común de los mortales. Para evitar esto vamos a convertir este valor en un formato más amigable: horas:minutos:segundos.milisegs.microseg.nanosegs
Para ello usaremos la facilidad que la librería chrono ofrece para convertir las unidades de tiempo: “duration_cast”. No es dificil:

chrono::nanoseconds nnsecs(1234129855342);
chrono::hours hrs = chrono::duration_cast<chrono::hours>(nnsecs);
chrono::minutes mins = chrono::duration_cast<chrono::minutes>(nnsecs % chrono::hours(1));
chrono::seconds secs = chrono::duration_cast<chrono::seconds>(nnsecs % chrono::minutes(1));
chrono::milliseconds mlsecs = chrono::duration_cast<chrono::milliseconds>(nnsecs % chrono::seconds(1));
chrono::microseconds mcrsecs = chrono::duration_cast<chrono:microseconds>(nnsecs % chrono::milliseconds(1));
nnsecs = chrono::duration_cast<chrono::nanoseconds>(nnsecs % chrono::microseconds(1));
cout << hrs << mins << secs << mlsecs << mcrsecs << nnsecs << endl;

Las conversiones deben ser desde las unidades con más precisión a las que tienen menos precisión, no podemos capturar una duración en horas y luego obtener de ese valor los milisegundos, el tamaño del tick no lo permite. Así que empezamos por los nanosegundos, que es la unidad en la que se calculan las duraciones y empezamos a calcular:

  • chrono::duration_cast<chrono::hours>(nnsecs): convierte los nanosegundos a horas y desecha el residuo
  • chrono::duration_cast<chrono::minutes>(nnsecs % chrono::hours(1)): toma el residuo de lo que queda de la conversión a horas y lo convierte en minutos.
  • chrono::duration_cast<chrono::seconds>(nnsecs % chrono::minutes(1)): toma el residuo de lo que queda de la conversión a minutos y lo convierte en segundos.
  • … y así sucesivamente… ¡en el futuro tendremos que hacer esto para los picosegundos y los femtosegundos!

Conviene tambien dejar por acá cómo sería una rutina para calcular el tiempo transcurrido entre dos lecturas de reloj, para medir el tiempo de ejecución de algún código (printElapsedTime):

    auto startTime = std::chrono::steady_clock::now();
    //...
    // Lineas de código cuyo desempeño se quiere medir.
    //...
    auto endTime = std::chrono::steady_clock::now();
    printElapsedTime(startTime, endTime);
...
...
void printElapsedTime(chrono::time_point<chrono::steady_clock> startTime,
                      chrono::time_point<chrono::steady_clock> endTime) {
    auto elapsedTime = endTime - startTime;
    chrono::seconds secs = chrono::duration_cast<chrono::seconds>(elapsedTime);
    chrono::milliseconds mlsecs = chrono::duration_cast<chrono::milliseconds>(elapsedTime % chrono::seconds(1));
    chrono::microseconds mcrsecs = chrono::duration_cast<chrono::microseconds>(elapsedTime % chrono::milliseconds(1));
    elapsedTime = chrono::duration_cast<chrono::nanoseconds>(elapsedTime % chrono::microseconds(1));
    cout << secs << mlsecs << mcrsecs << elapsedTime << endl;
}

Tengo pensado usar esto para poder compartir con uds. la eficiencia en tiempo de diversos algoritmos que les estaré comentando por acá.

Espero les haya gustado, hasta la próxima.
e.

One Reply to “Cómo medir el tiempo de ejecución en C++”

Leave a Reply