Généralités

J’ai longtemps travaillé dans le développement de logiciels embarqués dans un décodeur numérique. Dans ce contexte, les ressources (cpu, mémoire flash, mémoire vive) sont limitées. La durée de vie d’un décodeur étant de plusieurs années, la mise à jour du logiciel embarqué nécessitait de toujours pouvoir en faire plus, plus vite mais avec les mêmes ressources.

En effet, il n’était pas possible de mettre à jour le hardware d’un décodeur déjà installé chez le client final, il aurait été trop onéreux de remplacer tous les décodeurs à chaque version du logiciel.
C’est pourquoi j’ai été régulièrement emmené à réaliser des études et cherché comment améliorer les performances ou limiter la consommation des ressources critiques.
De manière générale, il peut être nécessaire de faire une analyse détaillée de l’application pour :

  • Améliorer les performances,
  • Corriger les fuites mémoires (objets créés et qui ne sont pas normalement « garbage collecté »),
  • Corriger les problèmes de deadlock.

Les améliorations des performances peuvent être diverses et incluent notamment :

  • Le code exécuté (pour avoir un code plus rapide),
  • La mémoire permanente (limiter la consommation statique),
  • La mémoire temporaire (limiter les pics mémoires qui engendreraient des GC trop fréquents).

Avant de commencer à optimiser une fonction, il est important de faire des mesures pour :

  • Constater les performances actuelles. Cela sera utile pour quantifier le gain lors de la phase d’optimisation,
  • S’assurer que la fonction a besoin d’optimisation. Il est important de s’assurer qu’il y a un gain potentiel,
  • Une méthode pouvant appeler un certain nombre d’autres méthodes, il est important de localiser les zones exactes nécessitant une attention particulière pour l’optimisation.

Mesure de performance (benchmark)

Problème de la mesure

Lorsque l’on mesure les performances liées à un bout de code (pour simplifier, on supposera que ce code est délimité par une fonction), il est important de tenir compte des facteurs suivants qui influent sur les résultats mesurés:

  • Lors de la première exécution de la fonction, les classes nouvellement référencées vont être chargées (le fichier class est chargé en mémoire puis la structure est créé pour être accédée par le ClassLoader) puis initialisées (initialisation des variables et bloc statiques). Bien que souvent cela soit négligeable, dans certains cas cela peut être coûteux,
  • L’appel répété du même code va faire en sorte que la VM va le « jitter », c’est à dire transformer le byte code à interpréter en code natif bien plus efficace.

Ces 2 éléments vont expliquer que l’appel à la même méthode va évoluer au cours du temps indépendamment du contexte.

La VM étant « multi-threadée », il est aussi important de limiter l’activité externe et donc s’assurer que les mesures sont faites de manière unitaire sur la fonction.

Effet de bord d’une optimisation

Mis à part dans le cas (pas si rare) où le code utilise un mauvais algorithme ou est trop complexe, l’optimisation d’un code aura toujours une conséquence « négative » sur l’application. Par exemple, dans le cas d’optimisation de l’exécution du code, ces conséquences peuvent être:

  • Une plus grande consommation mémoire permanente,
  • Une plus grande consommation mémoire temporaire. Bien qu’on pourrait penser que cela n’a pas de conséquences réelles, n’oublions pas que cela va créer un pic de consommation mémoire et va provoquer des GC de manière plus fréquente et prendra plus de temps à effectuer sa tâche,
  • Une moins bonne lisibilité du code ou une complexification du code qui entraînera une maintenance plus difficile.

Pour être plus concret, prenons le cas d’une fonction qui effectue la recherche d’un élément dans un conteneur.
Pour implémenter le conteneur, plusieurs possibilités :

  • Une simple ArrayList. Les éléments ajoutés sont simplement mis à la fin de la liste. La recherche d’un élément sera lente mais la place occupée en mémoire est minimale.
  • Une HashMap. Les éléments ajoutés sont les clés et en valeur n’importe quel objet fera l’affaire. Si le hashCode des objets ajoutés est correctement implémenté (et c’est un point important), la recherche sera extrêmement rapide mais cela va engendrer une surconsommation de mémoire (chaque élément ajouté fait partie d’un objet plus complexe permettant le stockage en mode hash). De plus, le rajout d’un élément dans le conteneur est un peu plus lent que dans une liste et il est important que la fonction hashCode soit efficace pour limiter l’overhead.
  • Une liste triée. Étant une liste, cela prend juste la place nécessaire en mémoire. La recherche est relativement rapide mais le rajout d’un élément va être assez long car l’élément doit être rajouté au « bon » endroit. Par contre, cela suppose que les éléments peuvent être ordonné (il faudra donc aussi créer la fonction de comparaison).

On voit bien que le choix de la structure va avoir un impact direct sur les performances (mémoire et exécution). Il est donc judicieux de prendre la bonne structure en fonction de la contrainte du projet concernant cette fonction.

Il est important de balancer le gain de l’optimisation au surcoût engendré.

L’optimisation par le multithreading

Lorsque plusieurs tâches doivent être effectuées, il peut être intéressant de les lancer dans des threads séparés pour gagner en performance. Par contre l’exécution étant limitée par le nombre de processeurs, si les tâches n’effectuent que des activités utilisant le cpu à 100%, le gain sera limité par le nombre de processeurs présents (en espérant que chaque thread sera exécuté par un processeur différent). Par contre si les tâches sont en grande partie constituées d’activité d’attente (attente de résultat asynchrone comme une requête ip), mettre les taches en parallèle apportera un gain significatif sur les performances. En pratique, beaucoup de tâches étant une combinaison de ces 2 cas, gagner en performance de manière efficace nécessite que les tâches soient effectuées par un thread pool (pour limiter le nombre de thread en parallèle) et la taille du pool dépendra de l’activité moyenne nécessaire pour être optimum.

Optimisation du code généré

Il peut aussi être intéressant de diminuer le code généré (code compilé dans le .class) car il est très probable qu’un code ayant moins d’instructions (pour un algorithme identique) à exécuter sera plus performant. Sans oublier que dans le cas d’un environnement embarqué, la taille du code peut être critique !

Le coût de la synchronisation

Synchroniser un bloc de code ou une fonction a un léger coût sur la performance. De plus cela augmente la possibilité de créer des deadlock. Il est donc important de synchroniser au plus juste et de manière judicieuse.