Note sur les tests de performance 29 avril 2015 Cette note a pour but de vous initiez aux bonnes pratiques liées aux tests de performance ; en particulier, on va parler de compilation conditionnelle, de fichiers jar et de script de tests. Tout au long de cette note, on va utiliser un (mini) projet JAVA (totalement indépendant du TP) et c’est à vous d’adapter le travail pour le BE. Ce projet consiste à générer une liste d’éléments (Element est une classe contenant un attribut value de type entier), à ordonner par ordre croissant puis à retourner le premier élément. Téléchargez d’abord l’archive sort.tar.gz puis décompressez et compilez le code source avec votre environnement de développement préféré ou simplement avec la ligne de commande javac Sort/ *.java Pour l’exécution, la fonction main de la classe LaunchTest attend comme paramètre un entier qui représente la taille de la liste à générer. Pour tester, vous pouvez lancer la commande : java Sort.LaunchTest 7698908 Compilation conditionnelle La compilation conditionnelle permet au compilateur d’ignorer ou de compiler certaines parties du code source selon un test effectué au moment de la compilation. En java, il faut d’abord déclarer la condition avec public static final boolean condition et lui attribuer la valeur faux ou vrai. Bien entendu, cette valeur ne peut jamais être modifiée durant l’exécution à cause du mot clé final. Par conséquence, le compilateur ne va pas générer les lignes de code qui dépendent de la condition. A titre d’exemple, dans la classe Constants, on trouve les deux variables printDebug et printTable qui assurent l’affichage de quelques informations durant l’exécution. public static final boolean printDebug = false; public static final boolean printTable = false; Regardez ensuite la méthode public int compareTo(Element e) de la classe Element 1 public int compareTo(Element e) { if (Constants.printDebug) System.out.println(" comparison between "+ this + " and "+ e); return this.value - e.value; } On constate que la ligne de code System.out.println(" comparison between "+ this + " and "+ e); dépendra de la valeur de Constants.printDebug. Comme cette constante vaut ‘false’, le compilateur va ignorer cette ligne puisqu’il sait d’avance qu’elle ne sera jamais appelée et donc ne la génère pas. L’utilité de la compilation conditionnelle est qu’elle permet de basculer entre différents ‘modes’ d’utilisation du code source (i.e. débogage, test, etc). Pour voir concrètement son effet, changez la valeur de printDebug à true, compilez puis relancez la commande : java Sort.LaunchTest 7698908 Bien entendu, pour faire les tests de performance, il est conseillé de mettre toutes les lignes d’affichage (e.g. println) dans un test avec compilation conditionnelle. Les fichiers jar Un fichier jar est un fichier compressé permettant d’archiver un projet JAVA afin de l’exécuter comme un programme indépendant. Pour générer le fichier jar de notre projet, il suffit de lancer depuis le projet dans Eclipse/Netbeans : ”Export..” ou bien avec les deux lignes de commandes suivantes : echo "Main-Class: Sort.LaunchTest" > Manifest.txt jar cfm ./fichiertest.jar Manifest.txt Sort/* La première ligne permet d’indiquer la classe qui contient la fonction main. Une fois le fichier jar généré, il suffit de le lancer depuis un terminal. Par exemple : java -Xmx1g -jar fichiertest.jar 7698908 La valeur 7698908 représente toujours le paramètre passé à la fonction main (qu’on peut bien sûr modifier selon le test qu’on veut faire). Remarquez qu’on a utilisé l’option -Xmx1g avec la commande java. A vous de chercher pourquoi.. Les scripts de test Une fois l’archive de test générée, on peut utiliser un script pour lancer l’exécutable .jar avec différentes configurations. Une méthode simple pour le faire sera de préparer un script (fichier .sh par exemple) qui lance les différentes configurations séquentiellement. Le fichier peut contenir par exemple : java java java java -Xmx1g -Xmx1g -Xmx1g -Xmx1g -jar -jar -jar -jar fichiertest.jar fichiertest.jar fichiertest.jar fichiertest.jar 2 91898 98123 98127 98298 > > > > 91898.txt 98123.txt 98127.txt 98298.txt Vous pouvez ainsi créer différents scripts et les lancer ensemble pour exploiter le parallélisme mais vérifiez d’abord le nombre de threads que la machine offre (à l’aide de la commande $ cat /proc/cpuinfo). Tests de validité et tests de performance Avant de rendre votre code 1 (aux enseignants, aux clients, ...) vous devez vous assurer : 1. que le code développé est correct. On parle alors de tests de validité (ou de tests fonctionnels). 2. que le code développé est efficace. On parle dans ce cas de tests de performance. Tests de Validité : est-ce que ça marche ? Lors des tests de validité vous devez vous assurer que les résultats fournis par votre code correspondent bien aux résultats attendus. Pour cela, il est impératif de considérer des cas d’applications variés. Avec ces tests de validité, vous devez nous convaincre que vos algorithmes fonctionnent correctement. Tests de Performance : comment ça marche ? Lors des tests de performance, vous allez caractériser le fonctionnement de votre code. – Que veut-on mesurer ? Il convient tout d’abord de définir les paramètres à mesurer, représentatifs du fonctionnement du code. – Dans quels cas d’application ? Il est nécessaire de sélectionner (ou de créer) des jeux de données représentatifs et d’être capables de justifier les choix effectués. – Comment faire les mesures ? Il faut outiller le code dont on veut évaluer les performances sur chacun des jeux de données et récupérer les valeurs mesurées. Voir les conseils ci-après. – Que faire des mesures obtenues ? Vous devez produire un analyse critique des valeurs mesurées. Ces analyses doivent permettre de caractériser les performances d’un code donné pris isolément mais aussi de comparer les performances relatives de codes entre eux. Pour cette étape d’analyse, posez-vous la question de ce que signifie ”nombre de chiffres significatifs”. Les cas d’application pour les deux types de test Pour ce BE et pour les algorithmes de Disjktra et Disjktra-AStar, nous vous proposons plusieurs cartes (routières et non routières). A vous d’imaginer des cas d’applications variés sur ces différentes cartes. Il faut couvrir suffisamment de cas pour être convaincants (trajets dans les deux sens, trajets courts, trajets longs, trajets impossibles, comparaison du trajet ABC et des trajets AB et BC, etc.) Vous devez être capables de justifier vos choix de cas d’application. 1. on suppose que le code développé répond au problème posé. Dans le cas général, il faut également s’en assurer ! 3 Rapellez-vous vos cours de statistique (un test de chaque cas, c’est varié, mais pas statistiquement valable). Pour le 2eme livrable, vous devez expliciter les tests de validité et les tests de performances que vous avez menés. Mesure du temps d’exécution en Java Compilation Just In Time (JIT) Lors de sa compilation le code source java est compilé dans un format binaire (les fichiers .class). Ce format binaire n’est pas directement exécutable sur le processeur, il doit être interprété par une “Java Virtual Machine” (JVM). Comme on peut s’y attendre, interpréter un format binaire est plus long que de directement exécuter des instructions sur le processeur. Pour palier à ce problème, la JVM a des mécanismes de compilation Just-In-Time : quand un morceau du code (typiquement une fonction) est utilisé très souvent, il est compilé en instructions machine (c’est à dire qui n’ont pas besoin d’interprétation par la JVM). for(int i=0 ; i<1000 ; i++) doSomething(); Sur l’example ci-dessus, il y a fort à parier que le premier appel de la fonction doSomething() soit plus long le dernier parce que entre temps la JVM aura compilé la fonction. Pour rendre les choses encore plus complexes, la compilation JIT prend elle aussi du temps de calcul. Pendant l’exécution de la boucle ci-dessus, une partie du temps d’exécution sera dédié à la compilation. Le ramasse miettes Une autre particularité du Java est la gestion de la mémoire. Comme vous avez pu le remarquer, vous créez de nouveaux objets avec le mot clé “new” (ce qui revient à allouer de la mémoire). En revanche, vous n’avez jamais besoin de libérer manuellement cette mémoire (l’équivalent des free/delete en C/C++). Comme la mémoire a besoin d’être libérée, la JVM a un ramasse-miettes (ou garbage collector/GC) qui se charge de libérer la mémoire des objets déréférencés (c’est à dire qui n’ont plus de pointeurs sur eux et ne sont donc plus utilisables). De temps en temps, le ramasse miettes (i) regarde tous les objets a qui de la mémoire a été allouée (ii) détermine ceux qui ne sont plus utilisables (c’est à dire déréférencés) (iii) libère la mémoire de ces objets. Là encore cette opération est coûteuse en temps de calcul et il est difficile de prévoir quand elle aura lieu. 1 2 3 4 5 6 7 i n t s t a r t D i j k s t r a = System . g e t C u r r e n t T i m e M i l l i s ( ) ; dijkstra (); i n t e n d D i j k s t r a = System . g e t C u r r e n t T i m e M i l l i s ( ) ; i n t s t a r t A S t a r = System . g e t C u r r e n t T i m e M i l l i s ( ) ; aStar ( ) ; i n t endAStar = System . g e t C u r r e n t T i m e M i l l i s ( ) ; 4 Dans le bout de programme ci-dessus la procédure dijkstra() va créer beaucoup d’objets (label du tas ...). L’appel suivant au ramasse miettes (après la procédure dijkstra) libérera la mémoire de tout ces objets. On peut distinguer plusieurs cas : – Le ramasse-miettes est invoqué entre les lignes 2-3. Dans ce cas le coût de nettoyer la mémoire du Dijkstra est comptabilisé dans le temps d’exécution du Dijkstra. – Le ramasse-miette est invoqué entre les lignes 5-7. Dans ce cas, le coût du nettoyage de la mémoire du Djikstra est imputé au A* (ce qui est problématique). – Le ramasse-miettes est invoqué entre les lignes 3-5 ou après la ligne 7. Dans ce cas le temps de travail du ramasse-miettes n’est pas comptabilisé. La JVM dispose d’un appel système pour forcer l’appel du ramasse-miettes : System.gc(). Cette méthode peut être utilisée pour s’assurer que la mémoire du précédent algorithme a bien été nettoyé avant d’invoquer le second. Conseils pratiques Assurez vous que la JVM a bien eu le temps de faire ses optimisations (compilation JIT) avant d’effectuer des mesures de performance. Un manière simple de faire ça consiste à faire tourner plusieurs fois le même algorithme et de mesurer son temps d’exécution uniquement pour la dernière exécution. Assurez vous que le nettoyage de la mémoire allouée par un algorithme n’est pas comptabilisé dans le temps d’exécution d’un autre algorithme. Des appels à System.gc() permet de forcer l’appel au ramasse miettes à un point précis du programme. 5