Optimisation des compilateurs/Les optimisations liées aux constantes

Beaucoup des optimisations des compilateurs sont liées à des expressions qui manipulent des constantes. Par exemple, certains calculs dont une opérande est constante peuvent être simplifiés. Nous allons les détailler ci-dessous.

La propagation des constantes

modifier

La première optimisation de ce type, la plus connue, est la propagation des constantes. Ce n'est pas un optimisation à proprement parler, vu qu'elle n'est pas directement à l'origine de gains de performance. Mais elle permet aux autres optimisations liés aux constants de donner le meilleur d'elles-mêmes. Sans propagation des constantes, l'élimination des calculs constants ou des conditions inutiles ne pourrait pas se faire. C’est pourquoi nous en parlons dès le début de ce chapitre.

Elle consiste à remplacer les variables dont la valeur est constante par la constante elle-même.

Par exemple, le code suivant :

const int a = 2;
const int b = 6;
const int c = 8;

int delta = b*b - 4*a*c;

peut être remplacé par :

const int a = 2;
const int b = 6;
const int c = 8;

int delta = 6 * 6 - 4 * 2 * 8;

L'élimination des calculs/conditions constants

modifier

Quand une expression a tous ses opérandes constants, on sait qu'elle va systématiquement renvoyer le même résultat (sauf dans quelques cas particuliers, que nous aborderons plus bas). Le compilateur peut alors calculer ce résultat et remplacer l'expression par celui-ci. Cette optimisation porte un nom : on parle de constant folding. Il en existe plusieurs cas particuliers, le plus simple étant celui des expressions arithmétiques. Nous n'allons pas l'aborder, mais allons cependant parler de quelques cas particuliers, assez intéressants à connaitre.

L'élimination des expressions arithmétiques

modifier

Le cas le plus simple est clairement celui où toutes les opérandes sont constantes : le compilateur peut alors faire le calcul lui-même et simplement mettre le résultat directement dans le code du programme. Par exemple, prenons le code suivant. Vous remarquerez qu'il s'agit de l'exemple obtenu dans la section précédente, le résultat de la propagation des constantes dans l'exemple cité.

const int a = 2;
const int b = 6;
const int c = 8;

int delta = 6 * 6 - 4 * 2 * 8;

Après application du constant folding, ce code devient tout simplement :

const int a = 2;
const int b = 6;
const int c = 8;

int delta = - 28;

Dans l'exemple précédent, l'expression voit l'intégralité de ses opérandes devenir constantes. Mais ce n'est pas systématique et il arrive que l'expression ne soit que partiellement simplifiée. Typiquement, certaines opérandes vont devenir constantes, alors que d'autres vont rester des variables. L'économie en calcul est quand même substantielle.

Il arrive que la propagation de constante ne permette pas d'économiser des calculs, mais transforme une opération entre deux variables en une opération agissant sur une variable et une constante. Le calcul n'est alors pas éliminé, mais transformé. Par exemple, prenons le code suivant :

const int a = 2;
int b = ... ; // la valeur de b est non-constante

int delta = a * b ;

La propagation de constantes va le simplifier en :

int b = ... ; // la valeur de b est non-constante

int delta = 2 * b ;

Le calcul n'a pas disparu, on n'a pas fait d'économie, le nombre d'opérations effectuées par le processeur est inchangé. Malgré tout, cela permet quelques simplifications. La première est que les opérandes constantes utilisent les modes d'adressages plus efficients, qui n'utilisent pas de registres. Dans l'exemple plus haut, on a fait disparaitre une variable, la valeur 2 est intégrée dans l'instruction d'addition via le mode d'adressage immédiat, ce qui a permis d'économiser un registre. Optimisation très importante !

Une autre source d'optimisation est qu'on peut remplacer le calcul par un autre, plus rapide. Pour en donner un exemple, une multiplication par deux peut être efficacement exécutée par un décalage à gauche ou par une simple addition de la valeur à elle-même. Comme autre exemple, multiplier une expression par 0 ou par 1 est inutile, le résultat étant connu à l'avance. Même chose pour une addition ou soustraction avec 0, ou une division par 1. Le compilateur peut reconnaitre de tels cas et supprimer l'instruction inutile. Mais nous verrons cela plus tard dans la suite du cours. Disons juste que pour le moment, c'est un optimisation qui permet d'en appliquer d'autres.

La propagation de constantes est fiable quand la déclaration de la constante et son utilisation se trouve dans le même fichier ou module. Dans le cas où ce n'est pas le cas, après compilation des deux modules (celui qui déclare la constante, et celui qui l'utilise), la modification ultérieure de la valeur de la constante requiert la recompilation du module l'utilisant ; sinon le module continue d'utiliser une version obsolète de la valeur de la constante.

L'élimination des conditions constantes

modifier

Le second cas particulier élimine les branchements de type if...else dont la condition est constante. Si jamais une condition se retrouve être toujours vraie ou fausse, alors le bloc if..else qui correspond est supprimé et seule la portion qui sera toujours exécutée sera alors intégrée dans le programme.

Par exemple, le code suivant :

int a;

if (true)
{
    a += 6;
}
else
{
    a += 9;
}

sera transformé en :

int a;
a += 6;

Évidemment, les conditions constantes (toujours vraies ou fausses) ne sont jamais écrites telles qu'elles dans un programme. Elles apparaissent généralement une fois que la propagation de constante a fait son travail. Et de manière générale, les conditions constantes sont presque toujours liées à l'application d'autres optimisations liées aux constantes, que nous verrons plus loin.

Les appels de fonctions avec des arguments constants

modifier

Un autre cas particulier est celui de l'appel du fonction avec des arguments constants. On précise que tous les arguments de la fonction doivent être constants pour que la technique fonctionne. Dans ce cas , le compilateur peut simplement calculer le résultat renvoyé par la fonction. Par exemple prenons ce code :

int abs (int n)
{
  if (n > 0)
    return n ;
  else
    return -n ;
}

int main(int n)
{
  int a = -4 ;
  return abs(a) ;
}

On voit que la fonction est appelée avec tous ses arguments constants. Le compilateur peut alors calculer son résultat et remplacer l'appel par une simple affectation du résultat. Le code se simplifie alors en :

int abs (int n)
{
  if (n > 0)
    return n ;
  else
    return -n ;
}

int main(int n)
{
  return 4 ;
}

Mais attention, cette optimisation n'est valable que dans un cas bien précis : celui des expressions et fonctions qui donnent le même résultat pour des opérandes identiques, c'est-à-dire les expressions et fonctions sans effet de bord. Il existe des calculs qui ne respectent pas cette règle, notamment certaines fonctions : ce sont des expressions ou fonctions dites impures.

Un compilateur n'a pas vraiment de moyens pour savoir si une fonction est pure ou impure : il devrait regarder et analyser le code de la fonction, ce qui demanderait trop de ressources pour un résultat qui n'en vaut pas le coup. Dans ces conditions, le compilateur considère toutes les fonctions comme étant impures. La conséquence, c'est que de très nombreuses optimisations seront rendues impossibles si jamais on trouve une fonction au mauvais endroit dans le code source.