variables static et performance

Alors voilà : comme je suis curieux et que j'aime bien aller au fond des choses pour mieux les maîtriser, je me suis livré à une petite expérience dont je ne comprends pas le résultat.

Cela concerne l'utilisation de variables locales en static.
Comme point de départ, je me suis dit : puisque mon arduino est un environnement d'exécution de programme fermé, pourquoi chercher à économiser de la RAM lorsque ce n'est pas nécessaire ?

Par exemple, si la taille cumulée de toutes mes variables internes représente moins de 1000 octets, l'allocation / désallocation dynamique de mémoire à chaque entrée / sortie d'une fonction est parfaitement inutile. Dans ce cas, cette gestion dynamique alourdit certainement le code généré, et diminue les performances à l'exécution.

J'ai donc fait un test pour mieux mesurer les impacts de l'utilisation de static

//programme d'évaluation des conséquences d'une déclaration des variables locales en static
//à exécuter avec la ligne ci-dessous active, puis commentée,
//et comparer les résultats, y compris la taille du code compilé

#define static_on

uint8_t bidon(uint8_t a)
{

#ifdef static_on
  static uint8_t c,d,i;
  static uint8_t buffer[16];
#else
  uint8_t c,d,i;
  uint8_t buffer[16];
#endif

//ne cherchez pas la logique des instructions ci-dessous : il n'y en a aucune
  c = a + 5;
  if (c&1)
  {
    d = a + c;  
  }
  else
  {
    d = a - c;
  }

  for (i=0;i<16;i++)
  {
    if (c>d+i)
    {
      buffer[i] = d;
    }
    else
    {
      buffer[i] = c;
    }
  }

  d=0;
  for (i=0;i<16;i++)
  {
    d += buffer[i];
  }

  return d; 
}

int freeRam ()
{
  extern int __heap_start, *__brkval; 
  int v; 
  return (int) &v - (__brkval == 0 ? (int) &__heap_start : (int) __brkval); 
}

void setup()
{
  const uint32_t imax=100000;
  uint32_t i, chrono;

  Serial.begin(9600);
  Serial.print("Execution avec les variables locales allouees en ");
#ifdef static_on
  Serial.println("statique ");
#else
  Serial.println("dynamique");
#endif
  Serial.print("Ram disponible : ");
  Serial.print(freeRam());
  Serial.println(" octets");

  Serial.print("Test de performance sur ");
  Serial.print(imax);
  Serial.println(" iterations...");

  chrono = millis();
  for (i=0;i<imax;i++)
  {
    bidon((uint8_t) i);
  }
  chrono = millis() - chrono;

  Serial.print("Temps d'execution total : ");
  Serial.print(chrono);
  Serial.println(" millisecondes");

  chrono = (chrono * 1000) / imax;
  Serial.print("Temps d'execution moyen : ");
  Serial.print(chrono);
  Serial.println(" microsecondes");
}

void loop()
{
  delay(5000);
}

Avec pour résultat :

Taille binaire du croquis : 3 282 octets
Execution avec les variables locales allouees en dynamique
Ram disponible : 1623 octets
Test de performance sur 100000 iterations...
Temps d'execution total : 2716 millisecondes
Temps d'execution moyen : 27 microsecondes


Taille binaire du croquis : 3 268 octets
Execution avec les variables locales allouees en statique 
Ram disponible : 1604 octets
Test de performance sur 100000 iterations...
Temps d'execution total : 3322 millisecondes
Temps d'execution moyen : 33 microsecondes

Conclusion :

  • l'écart de RAM disponible au lancement du programme (19 octets) correspond bien aux variables déclarées en static.
  • le code binaire généré est bien un poil plus concis, probablement lié au non appel de routines d'allocation / désallocation mémoire

MAIS : en static le programme est significativement plus lent à l'exécution !?

Donc en gros en static le programme fait moins de choses, mais met plus de temps pour le faire. :sweat_smile:

Et là je sèche...
Une idée ?

Parfaitement clair.

Une variable statique est 100% équivalente d'un point de vue stockage et accès mémoire à une variable globale. C'est juste la portée du nom qui est limitée à la fonction dans laquelle elle est définie.
Or l'accès aux variables globales se fait via une adresse sur 2 octets ce qui demande un accès indexé plus complexe.

Une variable locale est allouée dans la pile et son accès est effectué via un déplacement réduit (généralement 1 octet) par rapport au pointeur de pile. L'instruction d'accès est donc plus simple.
Résultat une performance supérieure.

A noter que les variables locales ne sont pas allouées par un malloc() dans le tas (heap) réservé à l'allocation dynamique. Elle sont dans la pile et "l'allocation" ne consiste qu'en une addition : on incrémente le pointeur de pile du nombre d'octets nécessaires au stockage des variables locales. Cela est très rapide.

Ce qui m'étonne c'est la différence de taille du code. Je ne comprend pas pourquoi le code est plus grand dans le cas des variables locales...

merci pour cette explication elle aussi très claire.

Je dirais que les 14 octets de plus du binaire "variables locales" sont justement ceux qui correspondent à l'incrémentation / décrémentation du pointeur de pile.

Dans le cas du binaire "variables statiques", il n'y a aucune autre variable locale à la fonction donc pas besoin de toucher au pointeur de pile.

Non
L'incrémentation du pointeur de pile c'est une instruction.

Il faudrait regarder le code généré.

Bonjour,

@bricoleau : il semblerait que tu confond plusieurs notions. Je vais essayer de t'expliquer de façon clair mais je garanti rien :grin:

Comme point de départ, je me suis dit : puisque mon arduino est un environnement d'exécution de programme fermé, pourquoi chercher à économiser de la RAM lorsque ce n'est pas nécessaire ?

Le fait d'utiliser static ou non n'est pas une façon d'économiser de la mémoire, c'est une façon d'architecturer ton code.

Si une variable n'as pas vocation d'être publique (= globale) elle doit être locale, sinon tu "pollues" l'espace de nom et dans un gros programme ça peut vite devenir un bourbier infâme.

Mais si la valeur de la variable doit rester la même entre deux appels de fonctions ?
C'est là que le mot clef "static" prend tout son sens. Une variable static est locale, mais en même temps ce comporte comme une variable globale. Elle est initialisée comme une variable globale, elle fonctionne comme une variable globale MAIS elle n'est pas exporté dans l'espace de nom globale.

A noter que le mot clef "static" a trois usage :

  • sur une variable globale : "static" rend la variable globale mais locale au fichier où elle est déclarée
  • sur une variable locale : "static" rend la variable locale "persistante", sa valeur sera conservé entre deux appels de la fonction où est déclaré la variable en question
  • sur un fonction : "static" rend la fonction locale au fichier, toute fonction qui n'est pas exporté dans un .h devrait être static
    (ça permet aussi d'optimiser le programme lors de la compilation)

Par exemple, si la taille cumulée de toutes mes variables internes représente moins de 1000 octets, l'allocation / désallocation dynamique de mémoire à chaque entrée / sortie d'une fonction est parfaitement inutile. Dans ce cas, cette gestion dynamique alourdit certainement le code généré, et diminue les performances à l'exécution.

Tu confonds allocation dynamique sur le tas (via malloc() et free()) et variables automatiques alloués sur la pile.

C'est totalement différents, l'allocation dynamique (la vrai) est régie par une liste chainée spécifiant quel blocs mémoire est libre ou non. Il y a tout un algo derrière qui est très gourmand et donne des résultats catastrophique quand on possède quelques Ko de RAM. (fragmentation mémoire, RAM overflow, ...)

L'allocation sur la pile c'est totalement différent, pour les "grosses données" tout ce fait via des offset par rapport à une adresse fixe, en terme technique c'est de "l'accès indirect".
C'est extrêmement rapide, en assembleur il suffit d'une instruction (il y a même deux registres dédiés pour ce genre de "pointeurs" un peu spéciaux sur les processeurs AVR).

Pour les petites données (8 bits, 16 bits, parfois 32bits) c'est encore plus simple : il (le compilateur) met tout dans les registres si il le peu.
Si tu est curieux la convention d'appel d'AVR-GCC est disponible ici :
http://gcc.gnu.org/wiki/avr-gcc

Sinon en terme d'optimisation ce genre de question "static VS pas static" c'est ce que l'on appelle des "micro optimisations". C'est comme dire "je vais utiliser une cuillère à soupe au lieu d'une cuillère à café pour vider cette piscine". Ce qu'il faut regarder c'est le reste du programme, comment il est fait et ou ça coince. En gros ce dire "je vais chercher une pompe vide cave pour vider cette piscine" :wink:

Merci c'est sympa d'avoir pris du temps pour expliciter la chose
Juste que cela ne m'apprend rien mais cela servira toujours à d'autres. :stuck_out_tongue:
Désolé d'avoir utilisé les termes "allocation dynamique" alors que je ne parlais pas de malloc.

Mon propos de départ n'était pas sur les notions de portée ou rémanence de variables (et donc sur la bonne manière de les déclarer, quand utiliser le static, comment architecturer le code etc.), mais seulement sur une question de performance dégradée que je ne comprenais pas.

En fait en amont de tout ça, il y a bien une préoccupation : dans le cas d'un programme qui empile des appels à des fonctions qui ont besoin de stocker des données temporaires en mémoire, et dans le contexte de RAM limitée de l'arduino, comment mieux maîtriser les risques de se retrouver avec un crash suite à un débordement de pile ?
A la limite je préférerais me faire jeter par le compilo pour insuffisance de RAM, plutôt qu'à l'exécution avec d'autres conséquences dans le monde réel.

Parlons simplement de réservation de mémoire (peu importe que ce soit de l'allocation dynamique dans le tas ou une augmentation de la taille de la pile).
Je vois mon arduino comme un système fermé (comme les systèmes embarqués), où la RAM non utilisée par le programme ne profitera à personne d'autre.
D'où l'idée de faire simple et robuste en définissant au départ un emplacement RAM fixe pour chaque variable manipulée dans le programme.
Evidemment cela ne dispense pas de conserver un minimum de RAM disponible pour la pile, qui sera utilisée pour gérer les appels de fonctions, mais le risque de crash me semble mieux maîtrisé.
Et évidemment aussi je ne suis pas dans une logique d'appels récursifs.
Et enfin attention : je ne suis pas en train de dire qu'il faut toujours déclarer toutes ses variables locales en static (ou toutes les passer en variables globales). C'est clairement contraire aux règles de bon coding, donc à réserver à des cas d'usage bien précis.

bricoleau:
A la limite je préférerais me faire jeter par le compilo pour insuffisance de RAM, plutôt qu'à l'exécution avec d'autres conséquences dans le monde réel.

A moins de faire du récursif le fait de se payer un stack overflow avec un appel de fonction est très limité.
Il suffit de regarder au niveau des tableaux et des structures, c'est eux qui bouffent le plus de mémoire.
Un autre truc à faire attention c'est les sous-appels de fonction, là aussi ça peut vite devenir problématique.

Si tu veut un audit de l'utilisation de la RAM à la compilation il faut soit avoir les variables en globale, soit en locale avec static. C'est les deux seuls types de variables vérifiés par l'utilitaire avr-size.
Autant dire qu'avoir toutes ses variables en globales ou locales static est absolument dégueulasse (même si je vois des prog dans ce genre tout les jours sur le forum :grin:).

bricoleau:
Évidemment cela ne dispense pas de conserver un minimum de RAM disponible pour la pile, qui sera utilisée pour gérer les appels de fonctions, mais le risque de crash me semble mieux maîtrisé.

Ce genre d'audit par fonction est très utile quand tu as un RTOS où la taille de chaque pile par taches doit être défini mais sinon pour un programme mono-tache c'est même pas la peine.

bricoleau:
Et enfin attention : je ne suis pas en train de dire qu'il faut toujours déclarer toutes ses variables locales en static (ou toutes les passer en variables globales). C'est clairement contraire aux règles de bon coding, donc à réserver à des cas d'usage bien précis.

Pour ça ont est bien d'accord :wink: