Mesurer le passage du temps

La question de savoir combien le temps requis pour réaliser une opération ou une groupe d'opérations dans un programme donné revient souvent en période de développement. Des questions comme mon programme me semble lent, mais pourquoi? reviennent régulièrement, surtout pour qui commence à rédiger des programmes ayant des besoins en calcul plus imposants.

Cet article ne s'intéressera pas à toutes les considérations propres à l'optimisation des programmes, à l'étude de la complexité des algorithmes et au repérage des zones susceptibles de consommer du temps de traitement dans un programme, mais donnera quelques trucs simples pour vérifier le temps requis pour réaliser une ou plusieurs opérations.

Nous n'irons pas jusqu'à montrer comment garantir la précision extrême des mesures, mais nous verrons comment prendre le pouls d'un programme de manière raisonnablement pratique dans la plupart des cas.

Si vos besoins en précision vont au-delà de ce qu'expose ce texte d'introduction, vous avez besoin d'outils spécialisés et de techniques plus sophistiquées que ce qui peut être proposé dans un article aussi simple que celui-ci.

Sorte de TL;DR

Si vous ne cherchez qu'un truc rapide pour mesurer la vitesse d'exécution d'une fonction en C++, voici ce que je fais en pratique (code C++ 14) :

#include <utility>
#include <chrono>
#include <type_traits>
inline auto maintenant() {
   return std::chrono::high_resolution_clock::now();
}
//
// Si f(args...) est void
//
template <class F, class ... Args>
   std::enable_if_t<
      std::is_void<std::result_of_t<F(Args...)>>::value, duree
   > tester(F f, Args &&... args) {
      auto avant = maintenant();
      f(std::forward<Args>(args)...);
      return maintenant() - avant;
   }
//
// Si f(args...) n'est pas void
//
template <class F, class ... Args>
   std::enable_if_t<
      !std::is_void<std::result_of_t<F(Args...)>>::value,
      std::pair<std::result_of_t<F(Args...)>, duree>
   > tester(F f, Args && ... args) {
   auto avant = maintenant();
   auto res = f(std::forward<Args>(args)...);
   auto d = maintenant() - avant;
   return make_pair(res, d);
}
// ...
int f(int, int);
#include <iostream>
int main() {
   using namespace std;
   using namespace std::chrono;
   auto res = tester(f, 2, 3); // appelera f(2,3)
   cout << res.first << " calcule en "
        << durarion_cast<milliseconds>(res.second).count()
        << " ms." << endl;
}

Il existe plusieurs raisons de vouloir mesurer le temps pour un programme, parmi lesquelles on trouve :

Conseils globaux

Si vous avez des besoins précis qui ne sont pas couverts ici, alors n'hésitez pas à creuser plus loin par vous-mêmes. Construisez des bancs d'essai réalistes et utiles. Par exemple, si vous devez entreposer un bloc de 100 Ko de données sur un support média chaque seconde pendant 3 heures sans jamais en manquer un seul, alors assurez-vous d'avoir validé votre capacité de le faire avant de passer en production: sauvegarder 100 Ko une fois est une chose simple mais, sur une période très longue, un tas de choses peuvent se passer qui risquent de faire en sorte qu'une des écritures, en chemin, ne soit pas faite au bon moment.

Assurez-vous que le test n'interfère pas avec ce qui est mesuré. Les résultats des tests seront habituellement rendus disponibles aux testeurs à travers des entrées/ sorties (console, fichier, peu importe) et ces opérations prennent beaucoup de temps, et du temps plus ou moins stable par-dessus le marché (beaucoup de facteurs interveiennent dans une entrée/ sortie). Évitez les allocations/ déallocations de mémoire à l'intérieur du tronçon de temps mesuré, à moins que ces opérations ne fassent partie du test, car ces opérations prennent un temps fortement dépendant du contexte.

Évitez d'interférer avec la séquence de tests: les les fenêtres modales (p. ex. : MessageBox()) et les lectures au clavier (p. ex. : lire une touche pour continuer) sont, dans la majorité, des cas, des pratiques inefficaces pour suivre le déroulement des opérations. Mieux vaut une batterie de tests très automatisés et une trace lisible a posteriori (dans un fichier, par exemple : voir plus bas) qu'un(e) humain(e) obligé(e) de cliquer Ok sans arrêt pendant des heures et des heures – ou même des minutes et des minutes; soyons honnêtes, c'est très abrutissant d'en être réduit(e) à cliquer sur Ok (clic clic clic...).

Développez le réflexe de conserver la trace de vos mesures et de vos tests dans des fichiers plutôt que de les faire apparaître à la console. Ceci vous permettra de procéder à des analyses plus approfondies des données obtenues sans vous forcer à rester inutilement attentive ou attentif pendant que les tests s'exécutent à toute allure. Utiliser un chiffrier électronique et regarder des courbes avec un boncafé est bien plus productif que de rester fixé(e) devant un écran console à voir défiler des entiers.

Faites plusieurs tests; se limiter à un seul test n'est pas significatif. Si vous voulez mesurer le temps d'exécution d'une fonction qui prend en général quelques millisecondes à se compléter, alors répétez le test quelques centaines de milliers de fois (avec une répétitive, évidemment) et assurez-vous de récupérer les résultats avec précision.

Enfin, prenez soin de garder des statistiques pertinentes. Le temps moyen requis pour une opération donne une image globale intéressante, et le plus petit temps requis pour réaliser une opération est aussi chose utile, mais il arrivera fréquemment que vous soyezs intéressé(e) par le temps maximal requis pour réaliser l'opération mesurée: si vous avez des contraintes strictes à respecter, c'est celui-là qui vous fera mal.

Idée de base

L'idée de base est simple. Supposons qu'on veuille mesurer le temps t requis pour exécuter la fonction f(), alors l'algorithme de base pour prendre une mesure sera :

avant ← maintenant()
f()
après ← maintenant()
écoulé ← après - avant

Dans la mesure où écoulé>=0 après exécution de cet algorithme, on devrait avoir obtenu le temps requis pour exécuter f().

Nous souhaitons que écoulé>=0 parce que nous présumons que le temps avance pendant que f() s'exécute, mais nous savons que les nombres d'un type donné sont situées entre deux bornes: si la valeur de avant est très près de la borne supérieure du type de données de maintenant(), alors il est possible que après-avant soit négatif pour cause de débordement. Dans ce cas, on peut réaliser des calculs fins pour savoir le nombre d'unités de temps passées entre avant et après, ou simplement rejeter la donnée.

Si écoulé==0, il faut se demander si l'unité de mesure choisie pour le test est suffisamment fine.

De manière plus globale, si on veut réaliser NTESTS tests et garder (a) le meilleur temps, (b) le pire temps et (c) le temps moyen, alors l'algorithme global ressemblera à ceci :

NTESTS ← ...
cpt ← 0
temps_total ← 0
temps_min ← 0
temps_max ← 0
Tant que cpt < NTESTS Faire
   avant ← maintenant()
   f()
   après ← maintenant()
   écoulé ← après - avant
   Si écoulé >= 0 Alors
      temps_total ← temps_total + écoulé
      Si cpt == 0 Alors
         temps_min ← écoulé
         temps_max ← écoulé
      Sinon
         Si temps < temps_min Alors
            temps_min ← écoulé
         Si temps > temps_max Alors
            temps_max ← écoulé
   cpt ← cpt + 1
moyenne ← temps_total / NTESTS

Le test en soi est constitué des lignes en caractères gras. Les autres opérations sont des opérations de contrôle et de compilation de statistiques. On présume ici que temps_total ne subira pas de débordements, et on ne conserve que les temps qui ne sont pas négatifs.

On incrémentera habituellement cpt même si le temps de la mesure a été rejeté, pour éviter une boucle infinie en situation (pour le moins irrégulière) où tous les échantillons de temps saisis seraient invalides. Si le nombre de tests conservés doit absolument être celui fixé par NTESTS, alors on aura le choix de traiter les débordements de la variable temps avec rigueur, ou encore d'incrémenter cpt seulement dans le cas où la valeur de temps est valide.

Choisir l'outil

Évidemment, pour pouvoir implémenter un tel algorithme, il faut savoir quels sont les outils disponibles pour saisir le temps courant. Il en existe beaucoup, on s'en doute, selon les langages de programmation et selon les plateformes.

En langage C et en langage C++

Depuis C++ 11, nous sommes avantageusement servis par les outils de l'en-tête standard <chrono>, qui sont maintenant bien en place dans les compilateurs les plus importants.

Exemple C++ de mesure avec les outils de <chrono>
//
// Opération à mesurer
//
void f() {
   //
   // Insérez le code désiré
   //
}
#include <iostream>
#include <chrono>
using namespace std;
using namespace std::chrono;
int main() {
   const int NTESTS = 100'000; // choisi avec soin (!)
   system_clock::duration temps_total = {},
                          temps_min   = {},
                          temps_max   = {};
   for(int cpt = 0; cpt < NTESTS;) {
      auto avant = system_clock::now();
      f();
      auto apres = system_clock::now();
      auto ecoule = duration_cast<milliseconds>(apres - avant);
      if (ecoule.count() >= 0) {
         temps_total += ecoule;
         if (!cpt)
            temps_min = temps_max = ecoule;
         else
         {
            if (ecoule < temps_min)
               temps_min = ecoule;
            if (ecoule > temps_max)
               temps_max = ecoule;
         }
         ++cpt;
      }
   }
   auto moyenne = temps_total.count() / static_cast<double>(NTESTS);
   cout << "[ Statistiques pour un appel à f() ]" << endl
        << "Temps minimal: " << temps_min.count() << " ms." << endl
        << "Temps maximal: " << temps_max.count() << " ms." << endl
        << "Temps moyen: "   << moyenne << " ms." << endl;
}

Si votre compilateur n'est pas à jour, vous avez tout de même accès aux outils du langage C, utiles encore avec C++ 03.

La fonction la plus connue pour saisir le temps courant en C ou en C++ est la fonction std::time(), rendue disponible dans les fichiers d'en-tête <time.h> (langage C) et <ctime> (langage C++). Cette fonction retourne le nombre de secondes depuis le 1er janvier 1970 à minuit (00:00:00), et n'offre donc qu'une précision très grossière.

La fonction std::time() retourne un std::time_t, qui correspond habituellement à un entier. Elle prend en paramètre l'adresse d'un std::time_t (qui peut être 0 ou nullptr, chaque fois au sens de pointeur nul). Si on passe un pointeur de std::time_t non nul à std::time(), alors la valeur insérée par std::time() dans la variable pointée sera la même que celle retournée par la fonction.

Exemple C++ de mesure avec time()
//
// Opération à mesurer. Probablement longue puisque nous
// mesurerons son temps d'exécution en secondes.
//
void f() {
   //
   // Insérez le code désiré
   //
}

//
// Pas nécessaire, mais va alléger l'affichage dans main() en
// nous y évitant de convertir chaque time_t en int de manière
// manuelle dans les affichages avec std::cout
//
#include <ostream>
#include <ctime>
#include <iostream>
using namespace std;
ostream& operator<<(ostream &os, time_t t) {
   return os << static_cast<int>(t); }
}
int main() {
   const int NTESTS = 100000; // choisi avec soin (!)
   time_t temps_total = 0,
          temps_min   = 0,
          temps_max   = 0;
   for(int cpt = 0; cpt < NTESTS;) {
      auto avant = time(nullptr);
      f();
      auto apres = time(nullptr);
      auto ecoule = apres - avant;
      if (ecoule >= 0)
      {
         temps_total += ecoule;
         if (!cpt)
            temps_min = temps_max = ecoule;
         else
         {
            if (ecoule < temps_min)
               temps_min = ecoule;
            if (ecoule > temps_max)
               temps_max = ecoule;
         }
         ++cpt;
      }
   }
   double moyenne = temps_total / static_cast<double>(NTESTS);
   cout << "[ Statistiques pour un appel à f() ]" << endl
        << "Temps minimal: " << temps_min << " secondes" << endl
        << "Temps maximal: " << temps_max << " secondes" << endl
        << "Temps moyen: "   << moyenne << " secondes" << endl;
}

Pour une précision plus grande tout en restant dans les fonctions standards de C et de C++ (donc dans le code indépendant de la plateforme), la fonction std::clock() de type std::clock_t retourne une valeur (entière) représentant le temps écoulé depuis le lancement du processus dans lequel l'appel est réalisé. Le temps est calculé en ombre de secondes multiplié par CLOCKS_PER_SEC, qui dépend de considérations matérielles (souvent, la valeur de CLOCKS_PER_SEC sera 1000, mais il ne faut pas compter là-dessus).

Un exemple utilisant cette fonction irait à peu près comme suit.

Exemple C++ de mesure avec clock()
//
// Opération à mesurer.
//
void f() {
   //
   // Insérez le code désiré
   //
}

#include <ctime>
#include <iostream>
using namespace std;
int main() {
   const int NTESTS = 100000; // choisi avec soin (!)
   clock_t temps_total = 0,
           temps_min   = 0,
           temps_max   = 0;
   for (int cpt = 0; cpt < NTESTS; ) {
      clock_t avant = clock();
      f();
      clock_t apres = clock();
      clock_t ecoule = apres - avant;
      if (ecoule >= 0)
      {
         temps_total += ecoule;
         if (!cpt)
            temps_min = temps_max = ecoule;
         else
         {
            if (ecoule < temps_min)
               temps_min = ecoule;
            if (ecoule > temps_max)
               temps_max = ecoule;
         }
      }
   }
   double moyenne = temps_total / static_cast<double>(NTESTS);
   cout << "[ Statistiques pour un appel à f() ]" << endl
        << "Temps minimal: " << temps_min << ' ' << CLOCKS_PER_SEC << "ièmes de seconde" << endl
        << "Temps maximal: " << temps_max << ' ' << CLOCKS_PER_SEC << "ièmes de seconde" << endl
        << "Temps moyen: "   << moyenne  << ' ' << CLOCKS_PER_SEC << "ièmes de seconde" << endl;
}

On obtient avec clock() des mesures plus précises qu'avec time(), évidemment. Si clock() est incapable d'obtenir les métriques requises pour faire son travail, alors elle retourne -1, ce qui pourrait être validé en début de programme ou à l'aide d'un singleton.

ValideurClock.h
#ifndef VALIDEUR_CLOCK_H
#define VALIDEUR_CLOCK_H
#include "Incopiable.h"
#include <ctime>
//
// Valider automatiquement l'usage de clock()
//
class ValideurClock
   : Incopiable
{
   ValideurClock() {
      if (std::clock() == -1) exit(-1); // BOUM!
   }
   // le singleton
   static ValideurClock singleton;
};
ValideurClock.cpp
#include "ValideurClock.h"
ValideurClock ValideurClock::singleton;

En langage Java

Sous Java, on peut obtenir le temps écoulé (en millisecondes) depuis le lancement du processus en appelant la méthode de classe currentTimeMillis() de la classe System.

Exemple Java de mesure avec System.currentTimeMillis()
public class Z {
   //
   // Opération à mesurer.
   //
   static class Démo {
      public void f() {
         //
         // Insérez le code désiré
         //
      }
   }
   public static void main(String [] args) {
      final int NTESTS = 100000; // choisi avec soin (!)
      long temps_total = 0,
           temps_min   = 0,
           temps_max   = 0;
      for(int cpt = 0; cpt < NTESTS; ) {
         Démo démo = new Démo();
         long avant = System.currentTimeMillis();
         démo.f();
         long après = System.currentTimeMillis();
         long écoulé = après - avant;
         if (écoulé >= 0) {
            temps_total += écoulé;
            if (cpt == 0) {
               temps_min = temps_max = écoulé;
            } else {
               if (écoulé < temps_min) {
                  temps_min = écoulé;
               }
               if (écoulé > temps_max) {
                  temps_max = écoulé;
               }
            }
            ++cpt;
         }
      }
      double moyenne = temps_total / (double) NTESTS;
      System.out.println("[ Statistiques pour un appel à f() ]");
      System.out.println("Temps minimal: " + temps_min + " millisecondes");
      System.out.println("Temps maximal: " + temps_max + " millisecondes");
      System.out.println("Temps moyen: "   + moyenne  + " millisecondes");
   }
}

On le voit, les différences entre Java, C et C++ sont petites, et on s'y retrouve facilement de l'un à l'autre.

Dans les langages .NET

Avec les langages .NET, on peut obtenir le temps écoulé à partir d'une instance de la classe Stopwatch, prise dans l'espace nommé System.Diagnostics.

Exemple C# de mesure avec Stopwatch
using System;

namespace z
{
   public class Z
   {
      //
      // Opération à mesurer.
      //
      class Démo
      {
         public void f()
         {
            //
            // Insérez le code désiré
            //
         }
      }

      public static void Main()
      {
         const int NTESTS = 100000; // choisi avec soin (!)
         long temps_total = 0,
              temps_min   = 0,
              temps_max   = 0;
         for (int cpt = 0; cpt < NTESTS; )
         {
            Démo démo = new Démo();
            var watch = new System.Diagnostics.Stopwatch();
            watch.Start();
            demo.f();
            watch.Stop();
            long écoulé = watch.Elapsed;
            if (temps >= 0)
            {
               temps_total += écoulé;
               if (cpt == 0)
                  temps_min = temps_max = écoulé;
               else
               {
                  if (temps < temps_min)
                     temps_min = écoulé;
                  if (temps > temps_max)
                     temps_max = écoulé;
               }
               ++cpt;
            }
         }
         double moyenne = temps_total / (double) NTESTS;
         Console.WriteLine("[ Statistiques pour un appel à f() ]");
         Console.WriteLine("Temps minimal: {0} millisecondes", temps_min);
         Console.WriteLine("Temps maximal: {0} millisecondes", temps_max);
         Console.WriteLine("Temps moyen: {0} millisecondes",   moyenne);
      }
   }
}

On le voit encore une fois, les différences entre C#, Java, C et C++ sont petites, et on s'y retrouve facilement de l'un à l'autre.

Selon la plateforme

Parfois, on peut souhaiter avoir recours à des outils de plus haute précision que ceux proposés par notre langage de programmation de manière portable. Il est fréquent qu'on doive alors avoir recours à des outils spécifiques au système d'exploitation.

Sous Win32, par exemple, on peut obtenir une mesure plus préciser qu'avec std::clock() en sollicitant la paire de fonctions QueryPerformanceCounter(), qui donne une mesure à haute précision en fonction de la fréquence d'un compteur rafraîchi très souvent, et QueryPerformanceFrequency(), qui indique la fréquence de ce compteur. Chaque plateforme aura son propre standard, ses propres types de données et ses propres fonctions.

Un exemple d'utilisation de ces fonctions suit. Pour plus de détails, consultez la documentation en ligne.

Exemple C++, mesure précise mais non portable
#include <iostream>
using namespace std;
#include <windows.h>
void f() {
   Sleep(1500); // exemple simplet
}
int main() {
   LARGE_INTEGER freq;
   if (!QueryPerformanceFrequency(&freq)) {
      cerr << "Zut, ça ne marche pas!" << endl;
      exit (-1);
   }
   cout << "La fonction f() prend...";
   LARGE_INTEGER avant, apres;
   QueryPerformanceCounter(&avant);
   f();
   QueryPerformanceCounter(&apres);
   cout << static_cast<double>(apres.QuadPart - avant.QuadPart) / freq.QuadPart * 1000.0;
   cout << " millisecondes à s'exécuter" << endl;
}

Si les mesures dont vous avez besoin nécessitent de tels outils, alors utilisez-les. Si possible, évidemment, isolez-les de manière à pouvoir écrire vos bancs d'essai une seule fois peu importe la plateforme.

Appliquer une approche RAII

En C++, il est plus simple et plus élégant encore d'avoir recours à des mécanismes RAII pour mesurer le passage du temps. Pour les besoins de l'exemple qui suit, nous utiliserons std::clock(), fonction utilisée plus haut dans d'autres exemples. Au besoin, remplacez cette fonction par d'autres mécanismes; la technique restera essentiellement la même.

Pour cet exemple un peu naïf, nous aurons recours à une petite classe nommée MesureDeTemps. Nous saisirons aussi à la construction le nom de la fonction à tester et le flux dans lequel projeter les résultats des mesures de performance saisies.

#ifndef MESURE_DE_TEMPS_H
#define MESURE_DE_TEMPS_H
#include <string>
#include <ctime>
#include <ostream>
class MesureDeTemps {
public:
   using value_type = std::clock_t;
   using str_type = std::string;
private:
   value_type avant;
   str_type nom;
   std::ostream &os;
public:
   MesureDeTemps(const str_type &nom, std::ostream &os)
      : nom{nom}, os{os}
   {
      avant = now();
   }
   value_type now() const noexcept {
      return std::clock();
   }
   ~MesureDeTemps();
};
#endif

Une instance de MesureDeTemps saisira, dans son constructeur, le moment présent. Dans son destructeur, elle saisira à nouveau le moment présent, puis fera la différence entre ces deux valeurs et affichera le temps écoulé sur le flux en sortie choisi par le code client.

#include "MesureDeTemps.h"
#include <ostream>
#include <ctime>
using std::endl;
MesureDeTemps::~MesureDeTemps() {
   auto apres = now();
   os << "Temps écoulé pour " << nom << ": "
      << static_cast<double>(apres - avant) / CLOCKS_PER_SEC
      << " secondes" << endl;
}

Mesurer le temps d'exécution d'une fonction f() quelconque à l'aide d'une instance de MesureDeTemps devient d'une simplicité élémentaire. Le code dont on veut mesurer la vitesse d'exécution doit être entouré d'une paire d'accolades, délimitant ainsi la portée des entités qui y sont déclarées. Une instance de MesureDeTemps doit être déclarée dans ce bloc, juste avant le code à tester, et doit n'être suivie que de ce test, pour éviter de polluer les résultats obtenus. Le destructeur de l'instance de MesureDeTemps sera invoqué à la fin de la portée ce qui provoquera l'affichage du résultat de la mesure. 

// ...
int main() {
   // ...bla bla...
   {
      MesureDeTemps mt{"f()", cout};
      f();
   } // mt est détruit: affichage!
   // ...bla bla...
}

Exemple plus détaillé (mais plus complexe) d'approche RAII

Note rapide : ce qui suit était auparavant un texte se trouvant ailleurs sur le site, ayant été préparé dans le cadre d'un cours sur le parallélisme. Je le fusionnerai éventuellement de manière plus harmonieuse avec ce qui précède. D'ici lors, je vous prie de tolérer toute redondance et tout dédoublement d'information – je manque de temps.

Notez que les explications sont essentiellement présentées pour la version C++ 03, mais je vous propose aussi une version pour C++ 11 plus bas.

Ce qui suit montre comment il est possible de mettre en place un système RAII de mesure du temps écoulé par l'exécution d'un sous-programme, à l'aide d'outils de votre choix, tout en gardant la strate client portable.

L'idée derrière une démarche de mesure de performance d'un sous-programme (ou d'un programme entier) est qu'il est souvent utile, voire nécessaire, de valider des hypothèses de travail. Par exemple, est-ce qu'une optimisation donnée mène à des résultats concluants (car, soyons honnêtes, il arrive qu'une optimisation locale réduise les performances globales, tout comme il arrive que ce qui semblait être une optimisation entraîne des coûts inattendus, allant ainsi à l'encontre de la démarche d'optimisation)?

Parfois aussi, il arrive qu'on approche un problème selon des approches philosophiquement et qualitativement différentes, comme par exemple de manière séquentielle et de manière parallèle; les mérites de l'une et de l'autre des approches valent souvent la peine qu'on les mesure : une solution parallèle à un problème donné est-elle préférable à une solution séquentielle au même problème, malgré les coûts de la synchronisation encourus? Si oui, quels sont les paramètres environnementaux qui influencent ces gains (nombre de processeurs, nombre de threads, quantité de mémoire disponible, etc.)? À partir de quelle complexité d'échantillon à traiter voit-on des gains apparaître?

Parfois encore, on souhaitera comparer deux techniques, comme par exemple une synchronisation des accès concurrents à une zone de transit à l'aide de sections critiques, typiquement locales au processus, ou à l'aide d'un outil système comme le sont typiquement les mutex implémentés dans les systèmes d'exploitation les plus connus. Sachant que la contention influence l'impact de ces deux approches sur la performance d'ensemble du système, des métriques permettent de faire des choix éclairés.

L'optique de portabilité privilégiée ici repose sur un raisonnement relativement simple : personne n'a véritablement de temps à perdre. Si nous savons assurer la portabilité du code client de nos outils, toute migration du code client d'une plateforme à l'autre se trouvera par la suite allégée. L'alternative est d'utiliser le code local à la plateforme choisie directement dans le code client; cela fonctionne, mais implique des efforts plus importants pour toute migration ultérieure. Ça fonctionne aussi, bien entendu; le choix dépend de vous. Avis aux intéressé(e)s. Ce qui suit suppose que vous avez un intérêt pour cette démarche.

Fichier functional_extension.h

L'optique de base proposée ici va comme suit :

  • Il existe, en C et en C++, des fonctions standards de mesure du temps écoulé, comme std::time() et std::clock(). Ces fonctions sont portables, mais ont généralement une précision inférieure aux outils non-portables offerts par les systèmes d'exploitation les plus connus
  • La signature de std::time() est time_t time(time_t*), les noms time et time_t faisant tous deux partie de l'espace nommé std. On lui passe habituellement 0 (pointeur nul) en paramètre, et la valeur retournée est une sorte d'entier représentant le nombre de secondes depuis le 1er janvier 1970
  • La signature signature de std::clock() est clock_t clock(), les noms clock et clock_t faisant tous deux partie de l'espace nommé std. La valeur retournée est une sorte d'entier représentant le nombre de CLOCKS_PER_SEC (une macro, qui vaut souvent – mais pas nécessairement – 1000) depuis le lancement du processus en cours d'exécution
  • Selon les plateformes, divers outils de mesure existent et offrent des seuils de précision souvent très élevés. Plusieurs systèmes POSIX offrent par exemple gettimeofday() dont la précision va jusqu'à la microseconde, alors que les plateformes Microsoft Windows offrent QueryPerformanceCounter() et QueryPerformanceFrequency() qui permettent d'utiliser la vitesse du processeur comme référentiel
  • Vous remarquerez que tous ces outils ont des signatures différentes, allant du nombre de paramètres impliqués aux types de retour. Pourtant, pris globalement, dans chaque cas, si nous souhaitons connaître le temps écoulé lors de l'invocation d'une fonction f() quelconque, nous voudrions simplement (en pseudocode) écrire quelque chose comme :
avant ← maintenant()
f()
apres ← maintenant()
ecoule ← apres-avant
ecoule_millisecondes ← ecoule/UNITES_PAR_SECONDE*1000

Le problème qui nous attend est donc :

  • De trouver le moyen d'identifier automatiquement les types de retour des fonctions de mesure du temps (représentées par maintenant() dans le pseudocode), pour connaître les types des variables avant et apres, et
  • De trouver le moyen de ramener toutes les fonctions de mesure du temps à des fonctions sans paramètre, que nous nommerons ici des fonctions nullaires;

Les autres problèmes (identifier un référentiel, spécifier une action à prendre lorsque le calcul du temps écoulé a été fait) sont plus simples à régler et ne demandent pas de techniques avancées.

Tel que le montre le code à droite, pour identifier le type de retour d'un foncteur ayant zéro, un ou deux paramètres (on pourrait faire de même avec plus de paramètres, mais c'est sans intérêt sur le plan théorique), on peut y arriver en injectant des types internes et publics à titre documentaire. Ici, les types dont il faut dériver pour y arriver sont nullary_function (type maison pour un foncteur sans paramètre), std::unary_function ou std::binary_function (ces deux derniers étant des foncteurs standards de <functional>).

Pour intégrer harmonieusement fonctions et foncteurs, la solution la plus élégante présentement est d'avoir recours aux traits, représentés ici par nullary_function_traits et unary_function_traits. C++ 11 offre decltype() qui permet de simplifier cette technique.

Le code requis pour définir ces strates d'isolation est visible à droite. Remarquez la spécialisation des traits sur la base de la signature des fonctions... Avec cela, le type nullary_function_traits<std::clock>::return_type est std::clock_t, et le type de l'opérateur () d'un foncteur comme

struct mon_time
   : nullary_function<std::time_t>
{
   result_type operator()() const noexcept
      { return std::time(nullptr); }
};

...est std::time_t.

#ifndef FUNCTIONAL_EXTENSION_H
#define FUNCTIONAL_EXTENSION_H
#include <functional>
using std::unary_function;
using std::binary_function;
//
// Fonction nullaire: aucun parametre (donc fonction unaire
// "avec parametre void", pour garder la sauce homogene).
//
template <class R>
   struct nullary_function
      : unary_function<void, R>
   {
   };
//
// Fonctions "nulles": remplacantes ne faisant rien pour des fonctions
// nullaires, unaires et binaires (utiles pour des valeurs par defaut
// dans la specification de templates). Ce sont des "no-ops" classiques.
//
struct null_op
   : nullary_function<void>
{
   result_type operator()() const noexcept
      { }
};
template<class T>
   struct null_unary_op
      : unary_function<T,void>
   {
      result_type operator()(argument_type) const noexcept
         { }
   };
template <class T, class U>
   struct general_null_binary_op
      : binary_function<T, U, void>
   {
      result_type operator()(first_argument_type, second_argument_type) const noexcept
         { }
   };
template<class T, class U>
   struct null_binary_op
      : general_null_binary_op<T,U>
   {
   };
template<class T>
   struct null_binary_op<T,T>
      : general_null_binary_op<T,T>
   {
   };
//
// Traits, pour unifier la documentation de fonctions et de foncteurs
//
template <class F>
   struct binary_function_traits
   {
      typedef typename F::result_type result_type;
      typedef typename F::first_argument_type first_argument_type;
      typedef typename F::second_argument_type second_argument_type;
   };
template <class R, class A0, class A1>
   struct binary_function_traits<R(*)(A0, A1)>
   {
      typedef R result_type;
      typedef A0 first_argument_type;
      typedef A1 second_argument_type;
   };
template <class R, class A>
   struct binary_function_traits<R(*)(A, A)>
   {
      typedef R result_type;
      typedef A first_argument_type;
      typedef A second_argument_type;
   };
template <class F>
   struct unary_function_traits
   {
      typedef typename F::result_type result_type;
      typedef typename F::argument_type argument_type;
   };
template <class R, class A>
   struct unary_function_traits<R(*)(A)>
   {
      typedef R result_type;
      typedef A argument_type;
   };
template <class F>
   struct nullary_function_traits
   {
      typedef typename F::result_type result_type;
   };
template <class R>
   struct nullary_function_traits<R(*)()>
   {
      typedef R result_type;
   };
#endif
Fichier Minuterie.h

Armé de ces outils, il devient envisageable de définir une minuterie RAII capable d'utiliser diverses sortes d'opérations unaires. La classe Minuterie, à droite, en est un exemple.

Elle déduit le type de sont attribut avant_ du type retourné par timeOp, qu'il s'agisse d'une fonction ou d'une foncteur, et utilise une opération nulle (un no-op) par défaut comme action à réaliser lorsque la mesure du temps écoulé sera faite. Évidemment, il est probable que vous souhaitiez faire quelque chose de plus... signifiant, disons-le ainsi, lorsqu'une instance de Minuterie sera finalisée.

Dans cette illustration, j'ai choisi d'utiliser une opération binaire (deux opérandes) au nom générique InfoOp comme opération à réaliser lors de la finalisation d'une Minuterie, et un exemple d'une telle opération est le foncteur afficher_temps_clock.

Remarquez que la saisie du temps avant dans le constructeur de Minuterie se fait entre les accolades du constructeur, pas dans la séquence de préconstruction des attributs. Cette décision assure que la saisie du temps présent (à l'aide d'un TimeOp) soit la dernière étape de la construction d'une Minuterie, donc que l'initialisation des autres attributs n'interférera pas dans la captation des métriques.

Vous comprendrez qu'il est important qu'on ne dérive pas de Minuterie, à moins que le constructeur de l'enfant ne fasse partie de ce qui doit être mesuré.

#ifndef MINUTERIE_H
#define MINUTERIE_H
#include "functional_extension.h"
#include <ctime>
#include <iosfwd>
template <class Arg0, class Arg1>
   class base_afficher_temps
      : public binary_function<Arg0, Arg1, void>
   {
   protected:
      std::ostream &os_;
   public:
      base_afficher_temps(std::ostream &os)
         : os_(os)
      {
      }
   };
struct afficher_temps_clock
   : base_afficher_temps<std::clock_t, std::clock_t>
{
public:
   afficher_temps_clock(std::ostream &os)
      : base_afficher_temps(os)
   {
   }
   void operator()(first_argument_type avant, second_argument_type apres);
};
//
// Minuterie: objet RAII realisant une saisie de temps a la
// construction, une autre a la destruction, les deux a l'aide
// d'une instance d'operation nullaire TimeOp, puis realise
// un traitement a partir de ces deux saisies en invoquant
// l'operation binaire InfoOp.
//
// Le calcul du temps ecoule, par defaut, sera base sur la
// fonction standard clock(), de type clock_t
//
// Par defaut, InfoOp est un no-op.
//
// Il est essentiel que l'extrant d'un TimeOp puisse servir de
// 1er et de 2e parametre a un InfoOp pour que cet objet puisse
// etre compile
//
template <
   class TimeOp,
   class InfoOp = null_binary_op<
      typename nullary_function_traits<TimeOp>::result_type,
      typename nullary_function_traits<TimeOp>::result_type
   >
>
class Minuterie
{
public:
   using time_type = typename
      nullary_function_traits<TimeOp>::result_type;
   using first_argument_type = typename
      binary_function_traits<InfoOp>::first_argument_type;
   using second_argument_type = typename
      binary_function_traits<InfoOp>::second_argument_type;
private:
   TimeOp timeop;
   InfoOp infoop;
   time_type avant_;
public:
   Minuterie(TimeOp timeop, InfoOp infoop = InfoOp())
      : timeop(timeop), infoop(infoop)
   {
      avant_ = timeop();
   }
   ~Minuterie() noexcept
   {
      time_type apres_ = timeop();
      infoop(avant_, apres_);
   }
};
#endif
Fichier Minuterie.cpp

Pour ce qui est de l'implémentation de l'opérateur () de afficher_temps_clock, à titre d'exemple, on peut tout simplement évaluer le nombre de millisecondes écoulées pour la durée du test et projeter ce résultat sur un flux de sortie choisi au préalable.

Évidemment, il est possible d'enrichir ce traitement autant que souhaité, par exemple en ajoutant du traitement associé au débordement de temps alloué (pensez à de la calibration pour des systèmes en temps réel, par exemple) ou en identifiant les opérations mesurées (en injectant des informations pour identifier ce traitement à même le constructeur de l'opération).

#include "Minuterie.h"
#include <iostream>
using std::endl;
void afficher_temps_clock::operator()
   (first_argument_type avant, second_argument_type apres)
{
   const double DT = static_cast<double>(apres - avant);
   const double NB_MS =  DT / CLOCKS_PER_SEC * 1000.0;
   os_ << "Temps écoulé: " <<  NB_MS << " ms." << endl;
}
Fichier precise_clock.h

Un exemple d'implémentation d'outil de mesure non-portable mais harmonisé à notre infrastructure de saisie de métriques serait la classe precise_clock visible à droite.

Elle repose sur les fonctions de saisie du temps écoulé précises d'un système d'exploitation spécifique, fonctions qui ne respectent pas les usages génériques que nous avons adoptés (des opérateurs nullaires retournant une mesure de temps qu'il est possible de manipuler comme un nombre).

L'exemple montre donc comment sont encapsulées ces fonctions précises mais délinquantes (au sens de notre cadre de travail).

On pourrait aller plus loin et rendre l'en-tête complètement portable à l'aide de l'idiome pImpl.

#ifndef PRECISE_CLOCK_H
#define PRECISE_CLOCK_H
//
// Non portable
//
#include "functional_extension.h"
#include "Minuterie.h"
#include <cassert>
#include <ostream>
#include <windows.h>
#undef min
#undef max
class precise_clock
   : public nullary_function<LONGLONG>
{
   class frequency
   {
   public:
      using freq_type = result_type;
   private:
      freq_type freq_;
   public:
      frequency()
      {
         LARGE_INTEGER counts_per_second;
         bool freq_ok = QueryPerformanceFrequency(&counts_per_second) != 0;
         assert(freq_ok);
         freq_ = counts_per_second.QuadPart;
      }
      freq_type value() const noexcept
         { return freq_; }
   };
   static frequency _freq;
   friend class afficher_temps_precise_clock;
public:
   precise_clock()
   {
#ifdef _DEBUG
      LARGE_INTEGER perf_count_test;
      bool count_ok = QueryPerformanceCounter(&perf_count_test) != 0;
      assert(count_ok);
#endif
   }
   result_type operator()() const
   {
      LARGE_INTEGER perf_count;
      QueryPerformanceCounter(&perf_count);
      return perf_count.QuadPart;
   }
};
#include <string>
#include <vector>
class afficher_temps_precise_clock
   : public base_afficher_temps <precise_clock::result_type, precise_clock::result_type>
{
public:
   afficher_temps_precise_clock(std::ostream &os)
      : base_afficher_temps{os}
   {
   }
   void operator()(first_argument_type avant, second_argument_type apres);
};
#endif
Fichier precise_clock.cpp

L'implémentation du code d'affichage suit le même modèle que celui utilisé pour la classe afficher_temps_clock, plus haut. Encore une fois, plusieurs raffinements pourraient être apportés ici.

#include "precise_clock.h"
#include <ostream>
using std::endl;
precise_clock::frequency precise_clock::_freq;
void afficher_temps_precise_clock::operator()
   (first_argument_type avant, second_argument_type apres)
{
   assert(apres > avant);
   const double DT = static_cast<double>(apres - avant);
   const double NB_MS =  DT / precise_clock::_freq.value() * 1000.0;
   os_ << "Temps ecoule: " << NB_MS << " ms." << endl;
}

Reste à voir comment il est possible d'utiliser ces outils.

Fichier Principal.cpp (version un peu troup lourde)

Une utilisation possible est celle proposée à droite.

Vous remarquerez la lourdeur de la déclaration des variables de type Minuterie. Cette lourdeur tient du fait que le type d'une variable doit être connu du compilateur, dans le but de réserver suffisamment d'espace pour la loger. Ici, la classe étant générique, son type est paramétrique, et l'écriture de ce type est... disons déplaisante, mais surtout (en pratique) trop lourde pour être utilisée par le commun des mortels (et même par des spécialistes). La compréhension de trop de détails techniques est requise pour bien utiliser cette classe dans sa forme actuelle (oui, même les parenthèses autour de l'instanciation du precise_clock de la 2e instance de Minuterie sont nécessaires... On ne veut pas savoir tout ça pour être en mesure de faire des tests de performance!).

#include "Minuterie.h"
#include "precise_clock.h"
void f(); // fonction quelconque qu'on souhaite tester
void g(); // idem
#include <iostream>
using std::cout;
int main()
{
   {
      Minuterie<clock_t (*)(), afficher_temps_clock>
         minu(clock, afficher_temps_clock(cout));
      f();
   }
   {
      Minuterie<precise_clock, afficher_temps_precise_clock>
         minu((precise_clock()), afficher_temps_precise_clock(cout));
      g();
   }
}
Fichier Principal.cpp (pas mal mieux!)

Une autre utilisation possible, et nettement préférable à la précédente, est celle proposée à droite.

Ici, la logique complexe d'identification des types est cachée derrière la mécanique d'identification des types des paramètres à une fonction (la fonction tester() dans ce cas bien précis). Le programme principal (la fonction main()) est réduit à sa plus simple expression. Il est difficile de réduire le code client plus que cela : ici, chaque mot compte, et aucun n'est redondant.

#include "Minuterie.h"
#include "precise_clock.h"
void f(); // fonction quelconque qu'on souhaite tester
void g(); // idem
#include <iostream>
using namespace std;
template <class F, class M, class A>
   void tester(M mesure, F fct, A aff = null_binary_op<
       typename nullary_function_traits<M>::result_type,
       typename nullary_function_traits<M>::result_type
   >)
   {
      Minuterie<M, A> minu(mesure, aff);
      fct();
   }
int main()
{
   tester(clock, f, afficher_temps_clock(cout));
   tester(precise_clock(), g, afficher_temps_precise_clock(cout));
}

Ne reste plus qu'à insérer le code à tester dans les fonctions comme f() et g().

Pour profiter à plein de C++ 11

Le langage C++ a beaucoup évolué entre C++ 03 et C++ 11, et les pratiques qui se sont développées et codifiées au fil des ans ont mené au développement d'outils qui simplifient beaucoup l'écriture.

Voici donc une version C++ 11 de l'exemple précédent; là où ce dernier était relativement complexe, ce qui suit sera beaucoup plus simple, en plus d'être plus portable et plus court.

Examinons, un fichier à la fois, ce que nous dit cette version. Je ne répéterai pas les explications déjà offertes, me limitant à une explication sommaire des raffinements que nous permet le nouveau standard.

Fichier functional_extension.h (version précédente)

La raison d'être principale de ce fichier dans la version précédente était la définition de traits pour déduire aisément le type de retour de l'opération permettant de quantifier le concept de « maintenant ».

Avec C++ 11, certains des traits les plus fréquemment rencontrés dans la pratique ont été définis de manière standard dans un fichier standard très utile et nommé <type_traits>. L'un de ces traits, std::result_of, permet précisément de déduire cette information, comme nous le verrons un peu plus bas.

Ainsi, pour cette version, le fichier functional_extension.h n'est tout simplement pas requis.

// rien du tout
Fichier Minuterie.h (version précédente)

N'ayant plus besoin de types internes particuliers, j'ai aplani la hiérarchie des afficheurs. Je n'ai entre autres plus recours à std::binary_function, par exemple, qui est obsolète avec C++ 11.

Le type de retour d'un timeOp pour une Minuterie donnée est exprimé en termes du trait standard std::result_of.

#ifndef MINUTERIE_H
#define MINUTERIE_H
#include <chrono>
#include <iosfwd>
class afficher_temps {
   std::ostream &os;
public:
   using timept_t =
      std::chrono::system_clock::time_point;
   afficher_temps(std::ostream &os)
      : os{os}
   {
   }
   void operator()(timept_t avant, timept_t apres);
};
#include <type_traits>
#include <functional>
template <class TimeOp, class InfoOp>
   class Minuterie {
   public:
      using time_type =
         typename std::result_of<TimeOp()>::type;
   private:
      TimeOp timeop;
      InfoOp infoop;
      time_type avant;
   public:
      Minuterie(TimeOp timeop, InfoOp infoop = {})
         : timeop{timeop}, infoop{infoop}
      {
         avant = timeop();
      }
      ~Minuterie() {
         auto apres = timeop();
         infoop(avant, apres);
      }
   };
#endif
Fichier Minuterie.cpp (version précédente)

Peu de changements ici, outre peut-être le recours à auto pour réduire le couplage entre les expressions et le type de leur résultat.

#include "Minuterie.h"
#include <ostream>
#include <chrono>
using namespace std;
using namespace std::chrono;
void afficher_temps::operator()(timept_t avant, timept_t apres) {
   os << "Temps ecoule : "
      << duration_cast<milliseconds>(apres-avant).count()
      << " ms." << endl;
}
Fichier precise_clock.h (version précédente)

Ce fichier est de beaucoup allégé si on le compare au précédent du fait que C++ 11 supporte officiellement le type long long, ce qui permet d'y avoir recours et d'éliminer <windows.h> du portrait.

Notre en-tête est maintenant pleinement portable, le code non-portable ayant été relégué à un fichier source (ci-dessous).

#ifndef PRECISE_CLOCK_H
#define PRECISE_CLOCK_H
#include <iosfwd>
class precise_clock {
public:
   using result_type = long long;
private:
   class frequency {
   public:
      using freq_type = result_type;
   private:
      freq_type freq;
   public:
      frequency();
      freq_type value() const noexcept {
         return freq;
      }
   };
   static frequency freq;
   friend class afficher_temps_precise_clock;
public:
   precise_clock();
   result_type operator()() const;
};
class afficher_temps_precise_clock {
   std::ostream &os;
   using time_type = precise_clock::result_type;
public:
   afficher_temps_precise_clock(std::ostream &os)
      : os{os}
   {
   }
   void operator()(time_type avant, time_type apres);
};
#endif
Fichier precise_clock.cpp (version précédente)

Ce fichier a dû croître en taille en comparaison avec son prédécesseur, contenant maintenant les définitions de quelques méthodes, mais c'est un bien petit prix à payer pour la portabilité accrue des en-têtes qui en résulte.

Remarquez le retour à la syntaxe unifiée des fonctions pour l'opérateur () d'un precise_clock. Voyez-vous quel est le gain ici?

#include "precise_clock.h"
#include <ostream>
#include <cassert>
using namespace std;
precise_clock::frequency precise_clock::freq;
void afficher_temps_precise_clock::operator()
   (time_type avant, time_type apres)
{
   assert(apres > avant);
   const auto DT = static_cast<double>(apres - avant);
   const auto NB_MS =  DT / precise_clock::_freq.value() * 1000.0;
   os_ << "Temps ecoule: " << NB_MS << " ms." << endl;
}
#include <windows.h>
#undef min
#undef max
precise_clock::frequency::frequency() {
   LARGE_INTEGER counts_per_second;
   bool freq_ok = QueryPerformanceFrequency(&counts_per_second) != 0;
   assert(freq_ok);
   freq_ = counts_per_second.QuadPart;
}
precise_clock::precise_clock() {
#ifdef _DEBUG
   LARGE_INTEGER perf_count_test;
   bool count_ok = QueryPerformanceCounter(&perf_count_test) != 0;
   assert(count_ok);
#endif
}
auto precise_clock::operator()() const -> result_type {
   LARGE_INTEGER perf_count;
   QueryPerformanceCounter(&perf_count);
   return perf_count.QuadPart;
}
Fichier Principal.cpp (version précédente)

Enfin, le code de test demeure tout aussi simple qu'auparavant. Ces gains en écriture et en simplicité ont été obtenus à coût zéro.

#include "Minuterie.h"
#include "precise_clock.h"
#include <chrono>
#include <iostream>
using namespace std;
using namespace std::chrono;
template <class F, class M, class A>
   void tester(M mesure, F fct, A aff) {
      Minuterie<M, A> minu(mesure, aff);
      fct();
   }
void f(); // fonction quelconque qu'on souhaite tester
void g(); // idem
int main() {
   tester(system_clock::now, f, afficher_temps{cout});
   tester(precise_clock(), g, afficher_temps_precise_clock{cout});
}

Charmant, n'est-ce pas?

Encore plus charmant – Fonction génératrice

Pour alléger un peu plus la syntaxe de création d'une Minuterie<M,A> donnée, on peut avoir recours à une fonction génétratrice. Par exemple :

//
// version simple mais pas si mal
//
template <class NM, class A>
   Minuterie<M,A> minuterie(M m, A a) {
      return { m, a };
   }

Cette fonction fait presque de la magie. En effet, elle permet d'éviter complètement d'engluer le code de test avec des déclarations de types complexes pour le système de minuterie. Le code de test devient tout simplement :

//
// ...
//
template <class F, class M, class A>
   void tester(M mesure, F fct, A aff)
   {
      auto minu = minuterie(mesure, aff);
      fct();
   }
void f(); // fonction quelconque qu'on souhaite tester
void g(); // idem
int main() {
   tester(system_clock::now, f, afficher_temps{cout});
   tester(precise_clock(), g, afficher_temps_precise_clock{cout});
}

Plus simple, plus clair, et sans perte d'efficacité.

Version plus simple et plus générale encore

Avec l'avènement des templates variadiques, on peut maintenant généraliser le concept de tester le temps d'exécution d'une fonction étant donné un ensemble de paramètres. Par exemple :

template <class M, class F, class ... Args>
   typename M::duration tester(F f, Args && ... args) {
      auto avant = M::now();
      f(forward<Args>(args)...);
      return apres - M::now();
   }

... ce qui pourrait s'utiliser comme ceci :

// ...
#include <string>
#include <chrono>
using namespace std;
using namespace std::chrono;
int f(double, string);
int main() {
   cout << duration_cast<milliseconds>(tester<system_clock>(3.14159, "Yo")) << endl;
}

On pourrait, si la valeur de retour de la fonction testée est importante, adapter le tout pour retourner une paire faite du temps écoulé et de cette valeur, comme suit :

#include <utility>
template <class M, class F, class ... Args>
   auto tester(F f, Args && ... args) -> std::pair<decltype(f(forward<Args>(args)...)), typename M::duration> {
      auto avant = M::now();
      auto res = f(forward<Args>(args)...);
      return std::make_pair(res, apres - M::now());
   }

... ou encore, depuis C++ 14, comme suit :

#include <utility>
template <class M, class F, class ... Args>
   auto tester(F f, Args && ... args) {
      auto avant = M::now();
      auto res = f(forward<Args>(args)...);
      return std::make_pair(res, apres - M::now());
   }

... ce qui pourrait s'utiliser comme ceci :

#include <string>
#include <chrono>
using namespace std;
using namespace std::chrono;
int f(double, string);
int main() {
   auto res = tester<system_clock>(3.14159, "Yo"));
   cout << "Resultat: " << res.first << ", temps: " << duration_cast<milliseconds>(res.second) << endl;
}

Voilà.

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !