Collecte de quelques méthodes d'optimisation du code en langage C pour tout le monde

Dans cet article, j'ai rassemblé beaucoup d'expérience et de méthodes. L'application de ces expériences et méthodes peut nous aider à optimiser le code en langage C en termes de vitesse d'exécution et d'utilisation de la mémoire.

Introduction

Dans un projet récent, nous devions développer une bibliothèque JPEG légère qui s'exécute sur des appareils mobiles mais ne garantit pas une qualité d'image élevée. Au cours de la période, j'ai résumé quelques façons d'accélérer le programme. Dans cet article, j'ai rassemblé quelques expériences et méthodes.

L'application de ces expériences et méthodes peut nous aider à optimiser le code en langage C en termes de vitesse d'exécution et d'utilisation de la mémoire.

Bien qu'il existe de nombreux guides sur l'optimisation du code C, il existe très peu de connaissances sur l'optimisation en termes de compilation et sur la machine de programmation que vous utilisez.

Habituellement, pour que votre programme s'exécute plus rapidement, la taille du code du programme peut devoir augmenter. L'augmentation de la quantité de code peut avoir un effet négatif sur la complexité et la lisibilité du programme. Cela n'est pas autorisé lors de l'écriture de programmes sur de petits appareils tels que les téléphones portables et les PDA qui ont de nombreuses restrictions sur l'utilisation de la mémoire.

Par conséquent, lors de l'optimisation du code, notre devise doit être de nous assurer que l'utilisation de la mémoire et la vitesse d'exécution sont optimisées.

déclaration

En fait, dans mon projet, j'ai utilisé de nombreuses méthodes d'optimisation de la programmation ARM (le projet est basé sur la plate-forme ARM), et j'ai également utilisé de nombreuses méthodes sur Internet. Mais toutes les méthodes mentionnées dans l'article ne peuvent pas jouer un bon rôle. J'ai donc fait un recueil sommaire des méthodes utiles et efficaces. Dans le même temps, j'ai également modifié certaines des méthodes pour les rendre applicables à tous les environnements de programmation, sans se limiter à l'environnement ARM.

Où ces méthodes doivent-elles être utilisées ?

Sans cela, toutes les discussions seraient impossibles. La chose la plus importante à propos de l'optimisation du programme est de savoir où optimiser, c'est-à-dire de savoir quelles parties ou modules du programme s'exécutent lentement ou consomment beaucoup de mémoire. Ce n'est que lorsque chaque partie du programme est optimisée que le programme peut s'exécuter plus rapidement.

Les parties du programme qui s'exécutent le plus, en particulier les méthodes qui sont appelées à plusieurs reprises par les boucles internes du programme, doivent être optimisées.

Pour un codeur expérimenté, il est souvent simple de découvrir les parties du programme qui ont le plus besoin d'être optimisées. De plus, de nombreux outils peuvent nous aider à déterminer ce qui doit être optimisé. J'ai utilisé le profileur d'outils de performance intégré de Visual C++ pour savoir où le programme consomme le plus de mémoire.

Un autre outil que j'ai utilisé est Vtune d'Intel, qui est également bon pour détecter les parties les plus lentes d'un programme. D'après mon expérience, les boucles internes ou imbriquées, les méthodes d'appel de bibliothèques tierces sont généralement la principale cause de lenteur du programme.

Entier

Si nous sommes sûrs que les entiers ne sont pas négatifs, nous devrions utiliser unsigned int au lieu de int. Certains processeurs gèrent les entiers non signés non signés beaucoup plus efficacement que les entiers signés signés (c'est une bonne pratique et aide également le code auto-explicatif pour des types spécifiques).

Par conséquent, dans une boucle serrée, la meilleure façon de déclarer une variable entière int est :

register unsigned int variable_name;

N'oubliez pas que la vitesse de fonctionnement de l'entier est supérieure à celle du flotteur de type virgule flottante et que l'opération peut être directement effectuée par le processeur sans recourir à la FPU (unité d'opération à virgule flottante) ou à la bibliothèque d'opérations à virgule flottante.

Bien que cela ne garantisse pas que le compilateur utilisera des registres pour stocker les variables, ni que le processeur puisse gérer plus efficacement les entiers non signés, c'est commun à tous les compilateurs.

Par exemple, dans un package de calcul, si le résultat doit être précis à deux décimales près, nous pouvons le multiplier par 100, puis le convertir en nombre à virgule flottante le plus tard possible.

Division et reste

Dans les processeurs standard, une division 32 bits utilise 20 à 140 cycles pour le numérateur et le dénominateur. Le temps consommé par la fonction de division comprend un temps constant plus le temps consommé par chaque division de bit.

Time (numerator / denominator) = C0 + C1* log2 (numerator / denominator)
     = C0 + C1 * (log2 (numerator) - log2 (denominator)).

Pour un processeur ARM, cette version nécessite 20+4.3N cycles. Cette opération est coûteuse et doit être évitée si possible. Parfois, des expressions de multiplication peuvent être utilisées à la place de la division.

Par exemple, si nous savons que b est positif et que b*c est un entier, alors (a/b)>c peut être réécrit comme a>(c * b). Si vous êtes sûr que les opérandes ne sont pas signés, il est préférable d'utiliser la division non signée non signée, car elle est plus efficace que la division signée.

Division et reste combinés

Dans certains scénarios, les opérations de division (x/y) et de reste (x%y) sont requises. Dans ce cas, le compilateur peut renvoyer le résultat de la division et le reste en appelant une fois l'opération de division. Si nous avons besoin à la fois du résultat de la division et du reste, nous pouvons les écrire ensemble comme ceci :

int func_div_and_mod (int a, int b) 
{         
    return (a / b) + (a % b);    
}

Division par puissances de 2 et reste

Si le diviseur de la division est une puissance de 2, nous pouvons mieux optimiser la division. Le compilateur utilise des opérations de décalage pour effectuer la division. Par conséquent, nous devons définir le diviseur comme une puissance de 2 autant que possible (comme 64 au lieu de 66). Et gardez toujours à l'esprit que la division d'entiers non signés non signés fonctionne plus efficacement que la division d'entiers signés.

typedef unsigned int uint;

uint div32u (uint a) 
{
     return a / 32;
}
int div32s (int a)
{
    return a / 32;
}

Les deux divisions ci-dessus évitent d'appeler directement la fonction de division, et la division non signée non signée utilise moins d'instructions informatiques. La division signée prend plus de temps à s'exécuter en raison de la nécessité de passer à 0 et aux nombres négatifs.

Une alternative au modulo

Nous utilisons l'opérateur de reste pour fournir un modulo arithmétique. Mais parfois, vous pouvez utiliser l'instruction if pour effectuer l'opération modulo. Considérez les deux exemples suivants :

uint modulo_func1 (uint count)
{
    return (++count % 60);
}

uint modulo_func2 (uint count)
{
    if (++count >= 60)
        count = 0;
    return (count);
}

Préférez utiliser l'instruction if au lieu de l'opérateur de reste car l'instruction if s'exécute plus rapidement. Notez ici que la nouvelle version de la fonction ne fonctionnera correctement que si nous savons que le nombre d'entrées est compris entre 0 et 59.

Utiliser des indices de tableau

Si vous vouliez définir une variable sur une valeur de caractère qui représentait quelque chose, vous pourriez faire quelque chose comme ceci :

switch ( queue ) 
{
    case 0 :   letter = 'W';   
        break;
    case 1 :   letter = 'S';   
        break;
    case 2 :   letter = 'U';   
        break;
}

ou fais ceci :

if ( queue == 0 )  
    letter = 'W';
else if ( queue == 1 )  
    letter = 'S';
else  letter = 'U';

Un moyen plus propre et plus rapide consiste à utiliser des indices de tableau pour obtenir la valeur d'un tableau de caractères. comme suit:

static char *classes="WSU"; 
letter = classes[queue];

variable globale

Les variables globales ne sont jamais dans des registres. A l'aide de pointeurs ou d'appels de fonction, vous pouvez directement modifier la valeur d'une variable globale. Par conséquent, le compilateur ne peut pas mettre en cache la valeur d'une variable globale dans un registre, mais cela nécessiterait des lectures et des stockages supplémentaires (souvent inutiles) lors de l'utilisation de variables globales. Par conséquent, nous vous déconseillons d'utiliser des variables globales dans des boucles importantes.

Si la fonction utilise trop de variables globales, il est préférable de copier la valeur de la variable globale dans une variable locale afin qu'elle puisse être stockée dans un registre. Cette approche ne fonctionne que si la variable globale n'est utilisée par aucune fonction que nous appelons. Les exemples sont les suivants :

int f(void);
int g(void);
int errs;
void test1(void)
{  
    errs += f();  
    errs += g();
} 
void test2(void)
{  
    int localerrs = errs;  
    localerrs += f();  
    localerrs += g();  
    errs = localerrs;
}

Notez que test1 doit charger et stocker la valeur de la variable globale errs à chaque opération d'incrémentation, tandis que test2 stocke localerrs dans un registre et ne nécessite qu'une seule instruction informatique.

utiliser un alias

Considérez l'exemple suivant :

void func1( int *data )
{    
    int i;     
    for(i=0; i<10; i++)    
    {          
        anyfunc( *data, i);    
    }
}

Bien que la valeur de *data puisse ne jamais changer, le compilateur ne sait pas que anyfunc ne la modifiera pas, donc le programme doit la lire depuis la mémoire à chaque fois qu'elle est utilisée. Si nous savons que la valeur de la variable ne sera pas modifiée, alors l'encodage suivant doit être utilisé :

void func1( int *data )
{    
    int i;    
    int localdata;     
    localdata = *data;    
    for(i=0; i<10; i++)    
    {          
        anyfunc (localdata, i);    
    }
}

Cela fournit des conditions au compilateur pour optimiser le code.

Fractionnement variable du cycle de vie

Les registres du processeur étant de longueur fixe, le stockage des variables numériques du programme dans les registres est limité.

Certains compilateurs prennent en charge le « fractionnement de plage en direct », ce qui signifie que les variables peuvent être allouées à différents registres ou mémoires dans différentes parties du programme.

La durée de vie d'une variable commence avec la dernière affectation à celle-ci et se termine avec la dernière utilisation avant la prochaine affectation. Pendant le cycle de vie, la valeur de la variable est valide, c'est-à-dire que la variable est vivante. Entre différents cycles de vie, la valeur de la variable n'est pas nécessaire, c'est-à-dire que la variable est morte.

De cette façon, le registre peut être utilisé par le reste des variables, permettant au compilateur d'allouer plus de variables pour utiliser le registre.

Le nombre de variables qui doivent être allouées à l'aide de registres doit dépasser le nombre de durées de vie différentes des variables dans la fonction. Si le nombre de durées de vie différentes des variables dépasse le nombre de registres, certaines variables doivent être temporairement stockées en mémoire. Ce processus est appelé segmentation.

Le compilateur partitionne d'abord les variables les plus récemment utilisées pour réduire le coût du partitionnement. La méthode d'interdiction du fractionnement du cycle de vie variable est la suivante :

  • Limitez le nombre de variables utilisées : cela peut être réalisé en gardant les expressions dans la fonction simples, petites et en n'utilisant pas trop de variables. Diviser des fonctions plus grandes en fonctions plus petites et plus simples peut également bien fonctionner.

  • Utiliser le stockage des registres pour les variables fréquemment utilisées : cela nous permet d'indiquer au compilateur que la variable doit être utilisée fréquemment, elle doit donc d'abord être stockée dans un registre. Cependant, ces variables peuvent toujours être séparées des registres dans certaines circonstances.

type de variables

Le compilateur C prend en charge les types de base : char, short, int, long (y compris signé et non signé non signé), float et double. L'utilisation du type de variable correct est essentielle car cela peut réduire la taille du code et des données et augmenter considérablement les performances du programme.

variable locale

Nous devrions essayer de ne pas utiliser de variables locales de type char et short. Pour les types char et short, le compilateur doit réduire les variables locales à 8 ou 16 bits à chaque affectation. C'est ce qu'on appelle l'extension de signe pour les variables signées et l'extension de zéro pour les variables non signées.

Ces extensions peuvent être mises en œuvre en décalant le registre vers la gauche de 24 ou 16 bits, puis vers la droite du même nombre de bits selon le drapeau signé ou non signé, ce qui consomme deux opérations d'instruction informatique (l'extension zéro du type char non signé ne consomme que une instruction informatique).

De telles opérations de décalage peuvent être évitées en utilisant des variables locales de type int et unsigned int. Ceci est très important pour des opérations telles que le chargement de données dans des variables locales en premier, puis le traitement des valeurs de données de variables locales. Que les données d'entrée et de sortie soient 8 bits ou 16 bits, il vaut la peine de les considérer comme 32 bits.

Considérez les trois fonctions suivantes :

int wordinc (int a)
{   
    return a + 1;
}
short shortinc (short a)
{    
    return a + 1;
}
char charinc (char a)
{    
    return a + 1;
}

Bien que les résultats soient les mêmes, le premier fragment de programme s'exécute plus rapidement que les deux derniers.

aiguille

Nous devrions essayer de passer les données de structure par valeur de référence, c'est-à-dire utiliser des pointeurs, sinon les données passées seront copiées dans la pile, réduisant ainsi les performances du programme. J'ai vu un programme passer de très grandes structures par valeur qui pourraient être mieux faites avec un simple pointeur.

La fonction accepte le pointeur des données de la structure à travers le paramètre. Si nous sommes sûrs de ne pas changer la valeur des données, nous devons définir le contenu pointé par le pointeur comme une constante. Par exemple:

void print_data_of_a_structure (const Thestruct  *data_pointer)
{    
    ...printf contents of the structure...
}

Cet exemple indique au compilateur que la fonction ne modifie pas la valeur du paramètre externe (décoré avec const) et n'a pas besoin d'être lue à chaque accès. Assurez-vous également que le compilateur limite toutes les opérations de modification aux structures en lecture seule pour donner une protection supplémentaire aux données de structure.

chaîne de pointeurs

Les chaînes de pointeurs sont souvent utilisées pour accéder à des données structurées. Par exemple, les codes couramment utilisés sont les suivants :

typedef struct { int x, y, z; } Point3;
typedef struct { Point3 *pos, *direction; } Object;
 
void InitPos1(Object *p)
{
   p->pos->x = 0;
   p->pos->y = 0;
   p->pos->z = 0;
}

Cependant, un tel code doit appeler à plusieurs reprises p->pos pour chaque opération, car le compilateur ne sait pas que p->pos->x est identique à p->pos. Une meilleure façon est de mettre en cache p->pos dans une variable locale :

void InitPos2(Object *p)
{
   Point3 *pos = p->pos;
   pos->x = 0;
   pos->y = 0;
   pos->z = 0;
}

Une autre méthode consiste à contenir directement les données de type Point3 dans la structure d'objet, ce qui peut éliminer complètement l'utilisation d'opérations de pointeur sur Point3.

exécution conditionnelle

Les instructions d'exécution conditionnelle sont principalement utilisées dans les instructions if, mais également lors du calcul d'expressions complexes à l'aide d'opérateurs relationnels (<, ==, >, etc.) ou d'expressions booléennes (&&, !, etc.). Pour les fragments de code contenant des appels de fonction, l'exécution conditionnelle n'est pas valide car la valeur de retour de la fonction sera détruite.

Par conséquent, il est avantageux de garder les instructions if et else aussi simples que possible, afin que le compilateur puisse se concentrer sur elles. Les expressions relationnelles doivent être écrites ensemble.

L'exemple suivant montre comment le compilateur utilise l'exécution conditionnelle :

int g(int a, int b, int c, int d)
{
   if (a > 0 && b > 0 && c < 0 && d < 0)
   //  grouped conditions tied up together//
      return a + b + c + d;
   return -1;
}

Les conditions étant regroupées, le compilateur est capable de les traiter collectivement.

Expressions booléennes et vérification de plage

Une expression booléenne commune est utilisée pour déterminer si une variable se trouve dans une certaine plage, par exemple, pour vérifier si une coordonnée graphique se trouve dans une fenêtre :

bool PointInRectangelArea (Point p, Rectangle *r)
{
   return (p.x >= r->xmin && p.x < r->xmax &&
                      p.y >= r->ymin && p.y < r->ymax);
}

Voici un moyen plus rapide : x>min && x<max peut être converti en (unsigned)(x-min)<(max-min). Ceci est plus avantageux lorsque min est égal à 0. Le code optimisé est le suivant :

bool PointInRectangelArea (Point p, Rectangle *r)
{
    return ((unsigned) (p.x - r->xmin) < r->xmax &&
   (unsigned) (p.y - r->ymin) < r->ymax);
 
}

Expressions booléennes et comparaisons à valeur nulle

Le bit d'indicateur du processeur est défini après une opération d'instruction de comparaison. Les bits d'indicateur peuvent également être réécrits par des instructions arithmétiques de base et des instructions nues telles que MOV, ADD, AND, MUL, etc. Si l'instruction de données définit les drapeaux, les drapeaux N et Z seront également définis comme si le résultat était comparé à zéro. Le drapeau N indique si le résultat est une valeur négative et le drapeau Z indique si le résultat est 0.

En langage C, les drapeaux N et Z du processeur sont associés aux instructions suivantes : opération relationnelle signée x<0, x>=0, x==0, x!=0 ; opération relationnelle non signée x= =0, x !=0 (ou x>0).

Chaque fois qu'un opérateur relationnel est appelé en code C, le compilateur émettra une instruction de comparaison. Si l'opérateur est mentionné ci-dessus, le compilateur optimisera l'instruction de comparaison. Par exemple:

int aFunction(int x, int y)
{
   if (x + y < 0)
      return 1;
  else
     return 0;
}

Utilisez la méthode de jugement ci-dessus autant que possible, ce qui peut réduire l'appel d'instructions de comparaison dans les boucles critiques, réduisant ainsi la taille du code et améliorant les performances du code. Le langage C n'a pas de concept de bits d'emprunt et de débordement, par conséquent, il est impossible d'utiliser l'indicateur d'emprunt C et l'indicateur de débordement V directement sans l'aide de l'assembleur. Mais le compilateur prend en charge l'emprunt (débordement non signé), par exemple :

int sum(int x, int y)
{
   int res;
   res = x + y;
   if ((unsigned) res < (unsigned) x) // carry set?  //
     res++;
   return res;
}

Développement de la détection paresseuse

Dans une instruction comme if(a>10 && b=4), assurez-vous que la première partie de l'expression AND donne le résultat (ou le calcul le plus ancien et le plus rapide) le plus probable, de sorte que la deuxième partie n'ait pas besoin d'être exécutée .

Utilisez la fonction switch() au lieu de if...else...

Pour les jugements multi-conditions impliquant if...else...else..., par exemple :

if( val == 1)
    dostuff1();
else if (val == 2)
    dostuff2();
else if (val == 3)
    dostuff3();

Il pourrait être plus rapide d'utiliser switch:

switch( val )
{
    case 1: dostuff1(); break;

    case 2: dostuff2(); break;

    case 3: dostuff3(); break;
}

Dans l'instruction if(), si la dernière instruction est remplie, toutes les conditions précédentes doivent être testées une fois. Switch nous permet de ne faire aucun test supplémentaire. Si vous devez utiliser des instructions if...else..., placez la plus susceptible de s'exécuter en premier.

pause à deux points

Utilisez des sauts binaires au lieu d'empiler le code dans une colonne, ne faites pas quelque chose comme ça :

if(a==1) {
} else if(a==2) {
} else if(a==3) {
} else if(a==4) {
} else if(a==5) {
} else if(a==6) {
} else if(a==7) {
} else if(a==8)

{
}

Remplacez-la par la bissection suivante, comme suit :

if(a<=4) {
    if(a==1)     {
    }  else if(a==2)  {
    }  else if(a==3)  {
    }  else if(a==4)   {

    }
}
else
{
    if(a==5)  {
    } else if(a==6)   {
    } else if(a==7)  {
    } else if(a==8)  {
    }
}

ou comme suit :

if(a<=4)
{
    if(a<=2)
    {
        if(a==1)
        {
            /* a is 1 */
        }
        else
        {
            /* a must be 2 */
        }
    }
    else
    {
        if(a==3)
        {
            /* a is 3 */
        }
        else
        {
            /* a must be 4 */
        }
    }
}
else
{
    if(a<=6)
    {
        if(a==5)
        {
            /* a is 5 */
        }
        else
        {
            /* a must be 6 */
        }
    }
    else
    {
        if(a==7)
        {
            /* a is 7 */
        }
        else
        {
            /* a must be 8 */
        }
    }
}

Comparez les deux déclarations de cas suivantes :

gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAAfFcSJAAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==

======001

instruction switch vs table de recherche

Les scénarios d'application de Switch sont les suivants :

  • appeler une ou plusieurs fonctions

  • Définir la valeur de la variable ou renvoyer une valeur

  • Exécuter un ou plusieurs fragments de code

S'il existe de nombreuses étiquettes de cas, dans les deux premiers scénarios d'utilisation du commutateur, l'utilisation d'une table de recherche peut être effectuée plus efficacement. Par exemple, les deux manières suivantes de convertir des chaînes :

char * Condition_String1(int condition) {
  switch(condition) {
     case 0: return "EQ";
     case 1: return "NE";
     case 2: return "CS";
     case 3: return "CC";
     case 4: return "MI";
     case 5: return "PL";
     case 6: return "VS";
     case 7: return "VC";
     case 8: return "HI";
     case 9: return "LS";
     case 10: return "GE";
     case 11: return "LT";
     case 12: return "GT";
     case 13: return "LE";
     case 14: return "";
     default: return 0;
  }
}
 
char * Condition_String2(int condition) {
   if ((unsigned) condition >= 15) return 0;
      return
      "EQ\0NE\0CS\0CC\0MI\0PL\0VS\0VC\0HI\0LS\0GE\0LT\0GT\0LE\0\0" +
       3 * condition;
}

Le premier programme nécessite 240 octets, tandis que le second ne nécessite que 72 octets.

cycle

Les boucles sont une construction courante dans la plupart des programmes ; la plupart du temps d'exécution du programme se produit dans des boucles, il vaut donc la peine d'investir dans le temps d'exécution de la boucle.

boucle terminée

L'écriture des conditions de terminaison de boucle peut entraîner une surcharge supplémentaire si l'on n'y prend pas garde. Nous devrions utiliser une boucle qui compte jusqu'à zéro et une condition de terminaison de boucle simple. Les conditions de résiliation simples consomment moins de temps. Voir le calcul de n ci-dessous ! des deux programmes. La première implémentation utilise une boucle d'incrémentation et la seconde implémentation utilise une boucle de décrémentation.

int fact1_func (int n)
{
    int i, fact = 1;
    for (i = 1; i <= n; i++)
      fact *= i;
    return (fact);
}
 
int fact2_func(int n)
{
    int i, fact = 1;
    for (i = n; i != 0; i--)
       fact *= i;
    return (fact);
}

Le fact2_func du deuxième programme s'exécute plus efficacement que le premier.

Boucle for() plus rapide

C'est un concept simple mais très efficace. Habituellement, nous écrivons le code de la boucle for comme suit :

for( i=0;  i<10;  i++){ ... }

i boucle de 0 à 9. Si l'ordre des boucles ne nous dérange pas, nous pouvons écrire :

for( i=10; i--; ) { ... }

La raison pour laquelle cela est plus rapide est qu'il peut traiter la valeur de i plus rapidement - la condition de test est la suivante : i est-il différent de zéro ? Si c'est le cas, décrémentez la valeur de i. Pour le code ci-dessus, le processeur doit calculer "calculer i moins 10, sa valeur est-elle non négative ? Si non négative, incrémenter i et continuer".

Des boucles simples font une grande différence. De cette façon, i est décrémenté de 9 à 0, et une telle boucle s'exécute plus rapidement.

La syntaxe ici est un peu bizarre, mais légale. La troisième instruction de la boucle est facultative (une boucle infinie peut être écrite comme for(;;)). Le code suivant a le même effet :

for(i=10; i; i--){}

ou encore plus loin :

for(i=10; i!=0; i--){}

Ce que nous devons retenir ici, c'est que la boucle doit se terminer à 0 (cela ne fonctionnera donc pas si vous bouclez entre 50 et 80), et que le compteur de boucle est décrémenté. Le code qui utilise des compteurs de boucle incrémentés ne bénéficie pas de cette optimisation.

boucle de fusion

Si un cycle peut résoudre le problème, il est décidé de ne pas en utiliser deux. Mais si vous devez faire beaucoup de travail en boucle, cela ne rentre pas dans le cache d'instructions du processeur. Dans ce cas, deux boucles distinctes peuvent s'exécuter plus rapidement qu'une seule boucle. Ci-dessous un exemple :

gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAAfFcSJAAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==

======002

boucle de fonction

Il y a toujours un certain coût de performance lors de l'appel d'une fonction. Non seulement le pointeur du programme doit changer, mais les variables utilisées doivent être poussées sur la pile et de nouvelles variables allouées. Afin d'améliorer les performances du programme, il existe de nombreuses optimisations dans la fonction. Tout en maintenant la lisibilité du code du programme, la taille du code doit également être contrôlable.

Si une fonction est appelée fréquemment dans une boucle, incluez la boucle dans la fonction, ce qui peut réduire les appels de fonction répétés. code afficher comme ci-dessous:

for(i=0 ; i<100 ; i++)
{
    func(t,i);
}
-
-
-
void func(int w,d)
{
    lots of stuff.
}

doit être remplacé par :

func(t);
-
-
-
void func(w)
{
    for(i=0 ; i<100 ; i++)
    {
        //lots of stuff.
    }
}

déroulement de la boucle

Des boucles simples peuvent être déroulées pour de meilleures performances, au prix d'une taille de code accrue. Une fois la boucle déroulée, le nombre de boucles devrait devenir de plus en plus petit, de sorte que moins de branches de code sont prises. Si le nombre d'itérations de boucle n'est que de quelques-uns, la boucle peut être complètement déroulée pour supprimer le fardeau de la corruption de boucle.

Cela peut faire une grande différence. Le déroulement de la boucle peut apporter des économies de performances considérables car le code n'a pas à vérifier et à incrémenter la valeur de i à chaque fois qu'il boucle. Par exemple:

gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAAfFcSJAAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==

======003

Les compilateurs dérouleront généralement des boucles simples à nombre fixe d'itérations comme ci-dessus. Mais comme le code suivant :

for(i=0;i< limit;i++) { ... }

Le code suivant (exemple 1) est beaucoup plus long que l'utilisation d'une boucle, mais il est plus efficace. Définir la valeur de block-sie sur 8 ne convient qu'à des fins de test, tant que nous répétons le "contenu de la boucle" le même nombre de fois, cela aura un bon effet.

Dans cet exemple, la condition de boucle est vérifiée toutes les 8 itérations, pas à chaque fois. Comme le nombre d'itérations est inconnu, il n'est généralement pas développé. Par conséquent, dérouler la boucle autant que possible nous permet d'atteindre une meilleure vitesse d'exécution.

//Example 1
 
#include<STDIO.H>
 
#define BLOCKSIZE (8)
 
void main(void)
{
int i = 0;
int limit = 33;  /* could be anything */
int blocklimit;
 
/* The limit may not be divisible by BLOCKSIZE,
 * go as near as we can first, then tidy up.
 */
blocklimit = (limit / BLOCKSIZE) * BLOCKSIZE;
 
/* unroll the loop in blocks of 8 */
while( i < blocklimit )
{
    printf("process(%d)\n", i);
    printf("process(%d)\n", i+1);
    printf("process(%d)\n", i+2);
    printf("process(%d)\n", i+3);
    printf("process(%d)\n", i+4);
    printf("process(%d)\n", i+5);
    printf("process(%d)\n", i+6);
    printf("process(%d)\n", i+7);
 
    /* update the counter */
    i += 8;
 
}
 
/*
 * There may be some left to do.
 * This could be done as a simple for() loop,
 * but a switch is faster (and more interesting)
 */
 
if( i < limit )
{
    /* Jump into the case at the place that will allow
     * us to finish off the appropriate number of items.
     */
 
    switch( limit - i )
    {
        case 7 : printf("process(%d)\n", i); i++;
        case 6 : printf("process(%d)\n", i); i++;
        case 5 : printf("process(%d)\n", i); i++;
        case 4 : printf("process(%d)\n", i); i++;
        case 3 : printf("process(%d)\n", i); i++;
        case 2 : printf("process(%d)\n", i); i++;
        case 1 : printf("process(%d)\n", i);
    }
}
 
}

Compter le nombre de bits non nuls

En se déplaçant continuellement vers la gauche, en extrayant et en comptant les bits les plus bas, l'exemple de programme 1 vérifie efficacement combien de bits non nuls existent dans un tableau. L'exemple de programme 2 est déroulé en boucle quatre fois, puis le code est optimisé en combinant les quatre décalages en un seul. Le déroulement fréquent des boucles peut offrir de nombreuses possibilités d'optimisation.

//Example - 1

int countbit1(uint n)
{
  int bits = 0;
  while (n != 0)
  {
    if (n & 1) bits++;
    n >>= 1;
   }
  return bits;
}

//Example - 2

int countbit2(uint n)
{
   int bits = 0;
   while (n != 0)
   {
      if (n & 1) bits++;
      if (n & 2) bits++;
      if (n & 4) bits++;
      if (n & 8) bits++;
      n >>= 4;
   }
   return bits;
}

rompre la boucle tôt

Souvent, les boucles n'ont pas besoin de toutes les exécuter. Par exemple, si nous recherchons une valeur particulière dans un tableau, une fois trouvée, nous devons rompre la boucle le plus tôt possible. Par exemple : la boucle suivante trouve s'il y a -99 parmi 10 000 entiers.

found = FALSE;
for(i=0;i<10000;i++)
{
    if( list[i] == -99 )
    {
        found = TRUE;
    }
}
 
if( found ) 
    printf("Yes, there is a -99. Hooray!\n");

Le code ci-dessus fonctionne bien, mais nécessite que la boucle s'exécute jusqu'au bout, que nous l'ayons trouvée ou non. Une meilleure approche consiste à terminer la requête une fois que nous avons trouvé le nombre que nous recherchons.

found = FALSE;
for(i=0; i<10000; i++)
{
    if( list[i] == -99 )
    {
        found = TRUE;
        break;
    }
}
if( found ) 
    printf("Yes, there is a -99. Hooray!\n");

Si la donnée à vérifier se situe en 23ème position, le programme sera exécuté 23 fois, économisant ainsi 9977 cycles.

conception de la fonction

C'est une bonne habitude de concevoir des fonctions petites et simples. Cela permet aux registres d'effectuer certaines optimisations telles que l'allocation de variables de registre, ce qui est très efficace.

Consommation de performances des appels de fonction

La consommation de performances de l'appel de fonction sur le processeur est très faible et n'occupe qu'une petite partie de la consommation de performances du travail d'exécution de la fonction. Il existe certaines restrictions concernant le passage des paramètres dans les registres de variables de fonction. Ces paramètres doivent être compatibles avec les nombres entiers (char, shorts, ints et floats occupent tous un mot) ou avoir une taille inférieure à quatre mots (y compris les doubles et longs longs qui occupent deux mots).

Si la limite de paramètre est de 4, le cinquième mot et les mots suivants sont stockés sur la pile. Cela nécessite de charger des paramètres depuis la pile lors de l'appel de la fonction, augmentant ainsi la consommation de stockage et de lecture.

Regardez le code ci-dessous :

int f1(int a, int b, int c, int d) {
   return a + b + c + d;
}
 
int g1(void) {
   return f1(1, 2, 3, 4);
}
 
int f2(int a, int b, int c, int d, int e, int f) {
  return a + b + c + d + e + f;
}
 
ing g2(void) {
 return f2(1, 2, 3, 4, 5, 6);
}

Les cinquième et sixième paramètres de la fonction g2 sont stockés sur la pile et chargés dans la fonction f2, qui consommera 2 paramètres de stockage supplémentaires.

Réduire la consommation de passage des paramètres de fonction

Les méthodes pour réduire la consommation de passage de paramètres de fonction sont les suivantes :

  • Essayez de conserver les fonctions utilisant moins de quatre paramètres. De cette façon, la pile n'est pas utilisée pour stocker les valeurs des paramètres.

  • Si la fonction prend plus de quatre paramètres, essayez de vous assurer que la valeur de l'utilisation de ces derniers paramètres l'emporte sur le coût de leur stockage sur la pile.

  • Passez la référence de paramètre par pointeur au lieu de passer la structure de paramètre elle-même.

  • Mettre des paramètres dans une structure et les transmettre à des fonctions via des pointeurs réduit le nombre de paramètres et améliore la lisibilité.

  • Essayez d'utiliser des paramètres de type moins longs qui occupent deux mots. Pour les programmes qui nécessitent des types à virgule flottante, double doit être utilisé le moins possible car il occupe deux mots.

  • Évitez les arguments de fonction qui existent dans les deux registres et sur la pile (c'est ce qu'on appelle le fractionnement des paramètres). Les compilateurs actuels ne gèrent pas efficacement cette situation : toutes les variables de registre sont également placées sur la pile.

  • Évitez les paramètres variables. Les fonctions variadiques placent tous les arguments sur la pile.

fonction feuille

Une fonction qui n'appelle aucune fonction est appelée fonction feuille. Dans l'application suivante, près de la moitié des appels de fonction sont des appels à des fonctions feuilles. Les fonctions feuilles sont efficaces sur n'importe quelle plate-forme car elles n'ont pas besoin d'effectuer des stockages et des lectures de variables de registre.

La consommation de performance de la lecture des variables de registre est très faible par rapport à la consommation d'énergie du système causée par le travail effectué par la fonction feuille à l'aide de quatre ou cinq variables de registre. Donc, écrivez autant que possible les fonctions fréquemment appelées en tant que fonctions feuilles.

Le nombre d'appels de fonction peut être vérifié avec certains outils. Voici quelques façons de compiler une fonction dans une fonction feuille :

  • Évitez d'appeler d'autres fonctions : y compris les fonctions qui appellent à la place la bibliothèque C (telles que les fonctions de division ou de manipulation de nombres à virgule flottante).

  • Utilisez __inline() pour les fonctions courtes.

fonction en ligne

Les fonctions en ligne désactivent toutes les options de compilation. Décorer une fonction avec __inline entraîne le remplacement direct de la fonction par le corps de la fonction sur le site d'appel. De cette façon, le code appelle la fonction plus rapidement, mais augmente la taille du code, surtout si la fonction elle-même est volumineuse et appelée fréquemment.

__inline int square(int x) {
   return x * x;
}
 
#include <MATH.H>
 
double length(int x, int y){
    return sqrt(square(x) + square(y));
}

Les avantages de l'utilisation des fonctions en ligne sont les suivants :

  • Aucune charge d'appel de fonction. L'appel de fonction est directement remplacé par le corps de la fonction, il n'y a donc pas de consommation de performances telle que la lecture de variables de registre.

  • Plus petit paramètre passant la consommation. Puisqu'il n'est pas nécessaire de copier des variables, le coût de transmission des paramètres est moindre. Les compilateurs peuvent fournir de meilleures optimisations si les arguments sont des constantes.

L'inconvénient des fonctions en ligne est que s'il y a beaucoup d'endroits à appeler, la taille du code deviendra très grande. Cela dépend principalement de la taille de la fonction elle-même et du nombre d'appels.

Il est sage d'utiliser inline uniquement pour les fonctions importantes. Lorsqu'elles sont utilisées correctement, les fonctions d'inlining peuvent même réduire la taille de votre code : un appel de fonction génère quelques instructions informatiques, mais une version optimisée utilisant l'inlining peut générer moins d'instructions informatiques.

utiliser la table de recherche

Les fonctions peuvent souvent être conçues comme des tables de recherche, ce qui peut améliorer considérablement les performances. Les tables de recherche sont moins précises que les calculs habituels, mais pas très différentes pour les programmes généraux.

De nombreux programmes de traitement du signal (par exemple, un logiciel de démodulation de modem) utilisent de nombreuses fonctions sin et cos qui sont coûteuses en calculs. Pour les systèmes en temps réel, où la précision n'est pas particulièrement importante, une table de correspondance sin, cos peut être plus appropriée. Lorsque vous utilisez une table de recherche, essayez de placer des opérations similaires dans la table de recherche, ce qui est plus rapide et économise de l'espace de stockage que l'utilisation de plusieurs tables de recherche.

opération en virgule flottante

Bien que l'arithmétique en virgule flottante prenne du temps pour tous les processeurs, nous devons toujours l'utiliser lors de la mise en œuvre d'un logiciel de traitement du signal. Lorsque vous écrivez des programmes de manipulation en virgule flottante, gardez à l'esprit les points suivants :

  • La division en virgule flottante est lente. La division en virgule flottante est deux fois plus lente que l'addition ou la multiplication. Convertir la division en multiplication en utilisant des constantes (par exemple, x=x/3.0 peut être remplacé par x=x*(1.0/3.0)). La division par une constante est calculée au moment de la compilation.

  • Utilisez float au lieu de double. Les variables de type Float consomment mieux de la mémoire et des registres et sont plus efficaces en raison d'une faible précision. Si la précision est suffisante, utilisez float chaque fois que possible.

  • Évitez d'utiliser des fonctions transcendantales. Les fonctions transcendantales telles que sin, exp et log sont implémentées sous la forme d'une série de multiplications et d'additions (en utilisant l'extension de précision). Ces opérations sont au moins dix fois plus lentes que la multiplication habituelle.

  • Simplifie les expressions arithmétiques à virgule flottante. Le compilateur ne peut pas appliquer d'optimisations aux opérations en virgule flottante qui s'appliquent aux opérations sur les entiers. Par exemple, 3*(x/3) peut être optimisé en x, tandis que les opérations en virgule flottante perdent en précision. Par conséquent, il est nécessaire d'effectuer l'optimisation manuelle en virgule flottante nécessaire si le résultat est connu pour être correct.

Cependant, les performances des opérations en virgule flottante peuvent ne pas répondre aux exigences de performances d'un logiciel spécifique. Dans ce cas, la meilleure approche peut être d'utiliser l'arithmétique en virgule fixe. Lorsque la plage de valeurs est suffisamment petite, les opérations arithmétiques en virgule fixe sont plus précises et plus rapides que les opérations en virgule flottante.

autres trucs

Souvent, l'espace peut être échangé contre du temps. Cela peut conduire à un accès plus rapide si vous pouvez mettre en cache les données fréquemment utilisées au lieu de les recalculer. Tels que les tables de recherche sinus et cosinus ou les nombres pseudo-aléatoires.

  • Essayez de ne pas utiliser ++ et – dans les boucles. Par exemple : while(n–){}, parfois difficile à optimiser.

  • Réduisez l'utilisation des variables globales.

  • À moins qu'elle ne soit déclarée en tant que variable globale, utilisez static pour modifier la variable d'accès au fichier.

  • Dans la mesure du possible, utilisez une variable de la taille d'un mot (int, long, etc.), en les utilisant (au lieu de char, short, double, bitfield, etc.), la machine peut fonctionner plus rapidement.

  • Aucune récursivité n'est utilisée. La récursivité peut être élégante et simple, mais nécessite trop d'appels de fonction.

  • Le calcul de la racine carrée consomme beaucoup de performances sans utiliser la fonction racine carrée sqrt dans la boucle.

  • Les tableaux unidimensionnels sont plus rapides que les tableaux multidimensionnels.

  • Le compilateur peut effectuer des optimisations dans un seul fichier - évitez de diviser les fonctions associées en différents fichiers, le compilateur peut mieux les gérer (par exemple en utilisant inline) s'ils sont conservés ensemble.

  • Les fonctions simple précision sont plus rapides que la double précision.

  • La multiplication en virgule flottante est plus rapide que la division en virgule flottante - utilisez val*0.5 au lieu de val/2.0.

  • L'addition est plus rapide que la multiplication - utilisez val+val+val au lieu de val*3.

  • La fonction put() est plus rapide que printf(), mais moins flexible.

  • Utilisez les macros #define au lieu des petites fonctions couramment utilisées.

  • L'accès aux fichiers binaires/non formatés est plus rapide que l'accès aux fichiers formatés car le programme n'a pas besoin de convertir entre l'ASCII lisible par l'homme et le binaire lisible par la machine. Si vous n'avez pas besoin de lire le contenu du fichier, enregistrez-le en tant que fichier binaire.

  • Si votre bibliothèque prend en charge la fonction mallopt() (utilisée pour contrôler malloc), essayez de l'utiliser. Le réglage de MAXFAST améliore considérablement les performances des fonctions qui appellent malloc plusieurs fois. Si une structure doit être créée et détruite plusieurs fois par seconde, essayez de définir l'option mallopt.

Enfin et surtout - activez les optimisations du compilateur ! Cela semble évident, mais est souvent oublié lors du lancement d'un produit. Les compilateurs peuvent optimiser le code à un niveau inférieur et effectuer des optimisations spécifiques au processeur cible.

Original : http://www.codeceo.com/article/c-high-performance-coding.html

Déclaration de droit d'auteur : cet article provient d'Internet, transmet des connaissances gratuitement, et le droit d'auteur appartient à l'auteur original. S'il s'agit de problèmes de droits d'auteur, veuillez me contacter pour le supprimer.

 

 

Je suppose que tu aimes

Origine blog.csdn.net/zp1990412/article/details/123931470
conseillé
Classement