Performances des exceptions C++

Vlad Lazarenko

Traducteur

Alexandre Laurent

Vous utilisez les exceptions en C++ et vous vous demandez quel est l'impact de celles-ci sur les performances. Cet article détaille l'influence des exceptions sur votre code.

L'article est une traduction autorisée de C++ Exception Handling and Performance par Vlad Lazarenko.

Commentez

- Titre : Performances des exceptions C++

- Auteur : Vlad Lazarenko

- Traducteur : Alexandre Laurent

- Parution : 2 juillet 2012

- Mise à jour : 27 janvier 2013

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2013 Vlad Lazarenko. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Article original

- II. Introduction

- III. Utiliser les exceptions

- IV. Facilité contre vitesse

- V. Sous le capot

- VI. Plongeons dans l'assembleur

- VII. Exceptions à saut

- VIII. Connaître quel mécanisme d'exceptions est utilisé

- IX. Autres considérations de performances

- X. Conclusion

- XI. Références

- XII. Divers

I - Article original

Cet article est la traduction du billet C++ Exception Handling and Performance par Vlad Lazarenko.

II - Introduction

Les exceptions fournissent un moyen pour signaler des conditions spéciales qui modifient le flux habituel de l'exécution du programme. La gestion des exceptions peut se référer soit à l'implémentation dans un langage de programmation, soit au mécanisme matériel de l'ordinateur ou les deux à la fois.

De nombreuses personnes sont concernées par l'impact sur les performances des exceptions en C++. Par exemple, un de mes collègues croit que l'utilisation des exceptions doit être évitée à tout prix pour améliorer la vitesse d'exécution d'une application. Est-ce vrai ou pas ? Allons voir !

III - Utiliser les exceptions

Il est important de comprendre que les exceptions ne sont pas prévues pour le déroulement normal du programme, mais conçues pour les situations inattendues. Ces situations ne devraient pas arriver du tout. Toutefois, cela peut arriver, donc nous devons vérifier les erreurs. Il y a deux approches fondamentales : renvoyer un code d'erreur à partir de la fonction ou bien utiliser une exception. Par exemple, prenons une fonction qui implémente une division :

int divide (int x, int y)
{
return x / y;
}

Nous devons nous assurer que le diviseur est différent de zéro, car il n'est pas possible de diviser par zéro. Il y a deux façons de le faire. Une vérification des erreurs à la C ressemblera à ceci :

int divide (int x, int y, int & result)
{
if (y == 0)
return -1;
result = x / y;
return 0;
}

La vérification des erreurs en utilisant les exceptions C++ ressemblera à ceci :

int divide (int x, int y)
{
if (y == 0)
throw std::logic_error (&#8220;Division by zero&#8221;);
return x / y;
}

L'utilisation de ces deux fonctions sera différente. Pour la fonction en C, nous allons toujours vérifier la valeur de retour afin d'être sûr de la réussite de l'opération :

void foo (int x, int y)
{
int result;
if (divide (x, y, result) == 0)
{
// Division was successful. Do something with &#8220;result&#8221;.
}
else
{
// Une erreur s'est produite !
}
}

En C++ :

void foo (int x, int y)
{
try
{
int result = divide (x, y);
// Division was successful. Do something with &#8220;result&#8221;.
}
catch (const std::logic_error &)
{
// Une erreur s'est produite !
}
}

L'utilisation des exceptions est d'autant plus pratique que le déroulement du programme devient compliqué. Que se passe-t-il si on appelle deux fois la fonction « divide » ? Dans ce cas, dans un code similaire à ce que l'on écrirait en C, nous devons effectuer deux vérifications d'erreur :

void foo (int x, int y)
{
int result;
if (divide (x, y, result) == 0)
{
// La division a réussi. Faites quelque chose avec &#8220;result&#8221;.
}
else
{
// Une erreur s'est produite !
}

if (divide (y, x, result) == 0)
{
// La division a réussi. Faites quelque chose avec &#8220;result&#8221;.
}
else
{
// Une erreur s'est produite !
}
}

Mais en C++, seul un bloc try-catch est nécessaire :

void foo (int x, int y)
{
try
{
int result = divide (x, y);
// La division a réussi. Faites quelque chose avec &#8220;result&#8221;.
result = divide (x, y);
// La division a réussi. Faites quelque chose avec &#8220;result&#8221;, encore.
}
catch (const std::logic_error &)
{
// Une erreur s'est produite !
}
}

Maintenant, imaginons que nous devions appeler « divide » des dizaines ou centaines de fois. Et pour rendre cela encore plus complexe, imaginons que nous ayons plusieurs fonctions imbriquées et que pour chacune d'elles, nous vérifiions les erreurs. Il semble que l'utilisation des exceptions soit la voie idéale. Eh bien, c'est pour cela que les exceptions existent : pour rendre les choses plus faciles.

IV - Facilité contre vitesse

Il y a plein de choses qui vont rendre nos vies de programmeurs plus faciles. Mais quelquefois nos programmes doivent être rapides. Pas seulement rapides, mais les plus rapides du monde, ou même de tout l'univers. Nous devons sacrifier les facilités de programmation pour atteindre cet objectif. C'est le cas avec les programmes de transaction à haute fréquence, par exemple. Donc, si un programmeur écrit un tel programme, il va penser que la surcouche de la fonction « divide » avec un bloc try-catch ralentira son programme, même si c'est d'une nanoseconde par rapport au code équivalent d'erreurs en C. Il choisira la voie la plus compliquée et vérifiera le code d'erreur pour chaque appel de la fonction « divide ». Bien sûr, il prendra plus de temps pour atteindre son but, mais est-ce que ce sera la bonne décision ?

V - Sous le capot

Pour répondre à la question ci-dessus, nous devons plonger dans les détails d'implémentation et trouver comment les exceptions sont implémentées. À partir de ce point, il y a une immense différence entre le C++ et les langages de plus haut niveau tel que le Java, C#, Python ou d'autres. En C++, il y a deux approches pour la gestion d'exceptions durant l'exécution : l'approche « setjmp/longjmp » (que l'on appellera par la suite « à saut ») et la gestion d'exceptions « sans coût ».

La méthode à saut conserve le contexte lorsque l'on entre dans un bloc permettant de capturer les exceptions. Lorsque l'exception est levée, le contexte peut être immédiatement restauré sans avoir besoin d'utiliser la fenêtre de la pile d'appels. Cette méthode fournit une propagation très rapide de l'exception, mais ajoute un surcoût significatif lors de l'utilisation du gestionnaire d'exception, même lorsqu'aucune exception n'est lancée.

L'approche sans coût génère des tables statiques pour décrire les portées des exceptions. Aucun code dynamique n'est requis lors de l'entrée dans la fenêtre contenant le gestionnaire d'exception. Lorsqu'une exception est lancée, les tables sont utilisées pour contrôler un retour sur la pile d'invocation du programme afin de trouver le gestionnaire d'exception requis. Cette approche est considérablement plus lente pour la propagation des exceptions, mais n'a aucun surcoût lorsqu'aucune exception n'est lancée.

Il y a toujours des avantages et désavantages et nous avons un choix à faire. Si on prend en compte que les exceptions ne font pas partie du déroulement normal d'un programme, nous devons optimiser les cas les plus communs, soit lorsque les exceptions ne sont pas lancées et sacrifier les performances lors de leur gestion. Ainsi, de nombreux compilateurs C++ de qualité industrielle ont choisi d'utiliser l'approche sans coût.

VI - Plongeons dans l'assembleur

Retournons à notre fonction « divide » et comparons l'utilisation des codes d'erreur avec celui des exceptions sans coût. Voici le code de la première approche :

int divide (int x, int y, int & result)
{
if (y == 0)
return -1;
result = x / y;
return 0;
}

int foo (int & result)
{
volatile int x = 4, y = 28;
int d1, d2;
if (divide (x, y, d1) == -1)
return -1;
if (divide (y, x, d2) == -1)
return -1;
result = d1 + d2;
return 0;
}

int main ()
{
int result;
foo (result);
return result;
}

Et le code utilisant les exceptions :

int divide (int x, int y)
{
if (y == 0)
throw std::logic_error ("Division by zero");
return x / y;
}

int foo ()
{
volatile int x = 4, y = 28;
return divide (x, y) + divide (y, x);
}

int main ()
{
try
{
return foo ();
}
catch (const std::exception &)
{
return -1;
}
}

Et voici ce qui se passe vraiment dans l'exemple utilisant les codes de retour des fonctions (j'ai supprimé le code qui n'est pas exécuté pour des raisons de simplicité) :

__Z6divideiiRi:
pushq    %rbp
movq    %rsp, %rbp
movl    %edi, -4(%rbp)
movl    %esi, -8(%rbp)
movq    %rdx, -16(%rbp)
cmpl    $0, -8(%rbp)
jne    L2
;; Bloc de code lorsque la valeur de retour est -1 sauté. Nous allons toujours dans L2
L2:
movl    -4(%rbp), %eax
movl    %eax, %edx
sarl    $31, %edx
idivl    -8(%rbp)
movl    %eax, %edx
movq    -16(%rbp), %rax
movl    %edx, (%rax)
movl    $0, %eax
popq    %rbp
ret

__Z3fooRi:
pushq    %rbp
movq    %rsp, %rbp
subq    $24, %rsp
movq    %rdi, -24(%rbp)
movl    $4, -4(%rbp)
movl    $28, -8(%rbp)
movl    -8(%rbp), %ecx
movl    -4(%rbp), %eax
leaq    -12(%rbp), %rdx
movl    %ecx, %esi
movl    %eax, %edi
call    __Z6divideiiRi
cmpl    $-1, %eax
sete    %al
testb    %al, %al
je    L5
;; Bloc de code lorsque la valeur de retour est -1 sauté. Nous allons toujours dans L5
L5:
movl    -4(%rbp), %ecx
movl    -8(%rbp), %eax
leaq    -16(%rbp), %rdx
movl    %ecx, %esi
movl    %eax, %edi
call    __Z6divideiiRi
cmpl    $-1, %eax
sete    %al
testb    %al, %al
je    L7
;; Bloc de code lorsque la valeur de retour est -1 sauté. Nous allons toujours dans L7
L7:
movl    -12(%rbp), %edx
movl    -16(%rbp), %eax
addl    %eax, %edx
movq    -24(%rbp), %rax
movl    %edx, (%rax)
movl    $0, %eax
leave
ret

_main:
pushq    %rbp
movq    %rsp, %rbp
subq    $16, %rsp
leaq    -4(%rbp), %rax
movq    %rax, %rdi
call    __Z3fooRi
movl    -4(%rbp), %eax
leave
ret

Cela fait beaucoup de vérifications d'erreurs alors que l'on présume que cela n'arrive pas souvent ! Si nous devons encore appeler la fonction « divide », nous allons devoir ajouter plus de vérifications pour la valeur de retour « -1 » et le listing assembleur deviendra plus long à cause de ces vérifications. Voyons l'apparence du code utilisant les exceptions :

__Z6divideii:
pushq    %rbp
movq    %rsp, %rbp
pushq    %r12
pushq    %rbx
subq    $32, %rsp
movl    %edi, -36(%rbp)
movl    %esi, -40(%rbp)
cmpl    $0, -40(%rbp)
jne    L2
;; Bloc de code allouant et lançant l'exception. Nous allons toujours dans L2.
L2:
movl    -36(%rbp), %eax
movl    %eax, %edx
sarl    $31, %edx
idivl    -40(%rbp)
addq    $32, %rsp
popq    %rbx
popq    %r12
popq    %rbp
ret

__Z3foov:
pushq    %rbp
movq    %rsp, %rbp
pushq    %rbx
subq    $24, %rsp
movl    $4, -20(%rbp)
movl    $28, -24(%rbp)
movl    -24(%rbp), %edx
movl    -20(%rbp), %eax
movl    %edx, %esi
movl    %eax, %edi
call    __Z6divideii
movl    %eax, %ebx
movl    -20(%rbp), %edx
movl    -24(%rbp), %eax
movl    %edx, %esi
movl    %eax, %edi
call    __Z6divideii
addl    %ebx, %eax
addq    $24, %rsp
popq    %rbx
popq    %rbp
ret

_main:
pushq    %rbp
movq    %rsp, %rbp
pushq    %rbx
subq    $24, %rsp
call    __Z3foov
movl    %eax, %ebx
movl    %ebx, %eax
addq    $24, %rsp
popq    %rbx
popq    %rbp
ret
;; Le code de dépliage de la pile est appelé à partir de la table d'exception générée statiquement. Ce code est retiré et n'est jamais atteint dans notre exemple.

Voilà qui est beaucoup mieux ! Nous avons réussi à éviter deux vérifications de code de retour inutiles. Sinon, le reste du code qui va être exécuté est exactement le même.

VII - Exceptions à saut

Disons maintenant que nous avons un compilateur qui utilise l'approche « setjmp/longjmp » pour implémenter les exceptions. Même avec cette approche, la gestion des exceptions peut être plus rapide que la vérification des codes d'erreur. Prenons l'exemple suivant :

while (doContinue) {
try {
doSomeWork ();
}
catch (...) { /* do something about it! */ }
}

… qui sera évidemment plus lent que :

while (doContinue) {
if (doSomeWork () != 0) {
/* do something about it! */
}
}

… mais qu'en est-il de :

while (doContinue) {
try {
do {
doSomeWork ();
} while (doContinue);
break;
} catch (...) { /* do something about it! */ }
}

Dans l'exemple ci-dessus, nous définissons le point de remontée une seule fois et évitons de vérifier la valeur de retour de la fonction plusieurs fois. Bien sûr, ceci est le scénario, en supposant que les situations exceptionnelles n'arrivent pratiquement jamais. Mais c'est une supposition raisonnable. Dans le cas contraire, cette situation devrait être traitée comme un déroulement normal pour le programme et donc gérée autrement, sans exception, rendant le programme identique pour les programmeurs C et C++. Notez que l'optimisation ci-dessus n'a aucun sens avec le mécanisme sans coût.

VIII - Connaître quel mécanisme d'exceptions est utilisé

Malheureusement, le seul moyen pour trouver quel est le mécanisme utilisé pour la gestion des exceptions est de compiler un petit programme les utilisant et d'analyser l'assembleur généré.

IX - Autres considérations de performances

L'utilisation des exceptions rendra le binaire plus gros, quel que soit le mécanisme utilisé pour la gestion des exceptions. Donc si la taille du programme est plus importante que la vitesse d'exécution, les exceptions ne devraient pas être utilisées. Dans le cas où vous souhaitez avoir le maximum de performance avec un impact minimal sur la taille, vous devez rajouter des phases de tests afin de savoir quelle est la meilleure combinaison de code utilisant les retours de fonctions ou celui utilisant les exceptions afin d'atteindre vos objectifs(1).

X - Conclusion

Avoir de bonnes performances peut signifier de nombreuses choses différentes : de la vitesse d'exécution à la taille du binaire, en passant par le temps de développement.

Dans cet article, nous avons discuté de la vitesse d'exécution d'une application utilisant un contrôle des erreurs avec les retours de fonctions, à la C, relativement à une utilisant les exceptions, ainsi que du temps de développement nécessaire pour écrire le même programme avec ces deux approches.

Si la taille du binaire du programme compilé est le facteur le plus important, alors les exceptions ne devraient pas être utilisées.

Si la vitesse d'exécution ou la facilité de développement (ou les deux !) est le facteur le plus important alors un programmeur qui décide d'abandonner la facilité d'utilisation des exceptions pour la gestion des erreurs avec des codes de retour des fonctions, passera non seulement plus de temps à écrire le code, rendant le code plus compliqué, à se heurter au problème de décrire l'erreur (surtout dans un environnement multithread et en particulier lorsque le stockage local du thread ne peut pas être utilisé à cause d'une gestion des exceptions dans un autre thread lors de la programmation asynchrone), mais aussi rendra le programme plus lent.

XI - Références

Error and Exception Handling by Dave Abrahams

Exception Handling - Wikipedia

GNAT Exception Handling Control

Itanium C++ ABI: Exception Handling

XII - Divers

Merci à Flob90, Luc Hermitte et gbdivers pour son support à la traduction de cet article. Merci à ClaudeLELOUP pour sa relecture orthographique !

(1)Note de traduction : toutefois, il arrive toujours un moment, où, lorsque le nombre de if ajoutés est énorme, la taille dépassera celle du binaire utilisant les exceptions.