Pourquoi faire simple quand on peut faire compliqué ? Pour faire un article. Nous allons va voir ici au travers d’un exemple pourquoi le choix de la représentation d’une donnée, ici un pixel, peut influer sur les performances générales.

On se propose ici de parcourir différentes représentation d’une donnée destinée à contenir les composantes d’une couleur au format RGBA (tout ceci est évidemment applicable à d’autres formats). Il existe plusieurs façons de coder une couleur, on choisira celle utilisant 4 entiers compris entre 0 et 255 pour chaque composante : rouge, vert, bleu, et transparence. Le langage choisit est le C++.

Après avoir présenté le contexte d’utilisation, on passera en revue différentes représentations. Chacune d’entre elle sera commentée selon la clarté du code produit et la facilité de lecture/écriture qu’elle sous-tend. Et enfin au travers d’un exemple, on verra l’impact que cela peut avoir sur le temps d’exécution.

Contexte

On imagine que dans le cadre d’une application graphique, on dispose d’une classe image pour représenter… des images. Cette classe propose une méthode to_grayscale() permettant de convertir l’image en niveau de gris. Dans cette méthode, pour chaque pixel de l’image, on calcule sa valeur moyenne sur les 3 composantes rouge, verte, bleue. La couleur de chaque pixel est mise à jour avec la valeur obtenue.

class image
{
  public:

  // Constructor with image size as parameter
  image(size_t size);

  // Convert image to greyscale
  void to_greyscale();

  // Destructor
  ~image();

private:
  // Pixels array
  color* pixels;

  // Image size
  size_t size;
};

L’instanciation est l’appel de cette méthode sera réalisé dans main.cpp. On y reviendra plus tard.

Les représentations

On va parcourir 5 représentations possible d’un pixel :

  • une structure “naïve”,
  • un tableau d’entiers,
  • un champ de 32 bits,
  • une liste chaînée,
  • une table de hachage.

La structure

Pour représenter chaque composante, la façon de faire qui vient en premier à l’esprit est certainement de créer une structure regroupant les composantes de la couleurs en utilisant un attribut par composante..

Définition du type

struct color
{
  // Constructor
  color() = default;

  // Convert image to greyscale
  void to_greyscale();

  // Color channels
  unsigned int red;
  unsigned int green;
  unsigned int blue;
  unsigned int alpha;
};

Pas grand chose dire sur la lisibilité du code. On voit clairement apparaître les données à manipuler.

Calcul du gris

Étant donnée la facilité d’accès à chaque composante, il n’y a pas de difficulté particulière pour calculer la valeur moyenne des composantes.

void color::to_greyscale()
{
  // Compute the mean color
  unsigned int grey = (red + green + blue) / 3;

  // Update all channels
  red = grey;
  green = grey;
  blue = grey;
}

Avec un tableau en plus

Dans cette situation, on remplace les 4 champs de la structure précédente par un tableau de 4 entiers.

Définition du type

struct color
{
  // Constructor
  color() = default;

  // Convert image to greyscale
  void to_greyscale();

  // Channels data
  unsigned char channels[NB_CHANNELS];
};

La compréhension est dans ce cas légérement moins immédiate que dans le cas précédent. On comprend bien que chaque couleur sera représenté à l’aide de 4 composantes, mais l’accés à chacune d’entre ne découle pas immédiatement de définition du type. En l’absence de davantage de commentaire ou de documentation, on ne sait par exemple pas dans quel ordre se trouve les composantes dans le tableau.

Calcul du gris

Une fois que l’ordre des couleurs dans le tableau est connu, l’accès à chacune d’entre elles ne pose pas de problème. L’utilisation des boucles, qui rend le calcul légérement plus opaque que dans le cas précédent, implique peut⁻être un très léger effort de compréhension.

void color::to_greyscale()
{
  unsigned int grey = 0;

  // Compute the mean color
  for(int i=0; i<NB_CHANNELS; i++)
    grey += (unsigned int)channels[i];

  grey /= NB_CHANNELS;

  // Update all channels
  for(int i=0; i<3; i++)
    channels[i] = grey;
}

Le champs de bit

Chaque composante pouvant être contenue sur 8 bits, il est possible d’utiliser un seul entier de 32 bits pour contenir les 4 composantes.

Définition du type

struct color
{
  // Constructor
  color() = default;

  // Convert image to greyscale
  void to_greyscale();

  // Channel value
  unsigned int channels;
};

Dans le cas du champs de bits on divise tout par 3 par rapport au cas précédent : l’occupation mémoire et la facilité d’accés à chaque champs. Là encore il faudrait ajouter au moins un commentaire pour préciser comment les composantes sont ordonnées. Ce code pourrait convenir dans le cas de systèmes avec de fortes contraintes sur l’occupation mémoire.

Calcul du gris

void color::to_greyscale()
{
  // Compute the mean color
  unsigned int grey =
    (((channels >> 24) & 0xFF) +
     ((channels >> 16) & 0xFF) +
     ((channels >> 8 ) & 0xFF)) / 3;

  // Zero all channels but alpha value
  channels &= 0x000000FF;

  // Update all channels
  channels |= (grey << 24); // red
  channels |= (grey << 16); // green
  channels |= (grey << 8);  //  blue
}

L’accés à chaque composante indépendemment des autres se fait par décalage/masquage de bits. Le principal avantage de ce type est la possibilité d’utiliser la notation héxadécimale pour assigner une couleur à un pixel. Si on souhaite un pixel jaune, on peut écrire :

color pixel;
pixel.channels = 0xFFFF0000;

Liste chaînée

Ensuite on peut imaginer stocker les différentes composantes au sein d’une liste chaînée. Le rouge référence le bleu, qui référence le vert, qui référence la valeur alpha de transparence.

Définition du type

// Linked list element definition
struct channel
{
  // Channel value
  unsigned char value;

  // Link to next channel
  struct channel* next_channel;
};


struct color
{
  // Constructor
  color();

  // Convert image to greyscale
  void to_greyscale();

  // Destructor
  ~color();

  // Channels data
  channel red;  
};

Il n’apparaît pas au premier coup d’oeil que l’on s’apprête à manipuler une couleur à 4 composantes. Contrairement aux cas vu précédemment, une phase d’initialisation est nécessaire. Elle permet d’allouer les 3 derniers éléments (vert, bleu, alpha).

color::color()
{
  // Initialize red channel
  red.value = 0;

  // Initialize green channel
  red.next_channel = new channel();
  red.next_channel->value = 0;

  // Initialize blue channel
  red.next_channel->next_channel = new channel();
  red.next_channel->next_channel->value = 0;

  // Initialize alpha channel
  red.next_channel->next_channel->next_channel = new channel();
  red.next_channel->next_channel->next_channel->value = 0;
}

Au delà d’une compréhension qui cette fois-ci n’est pas immédiate, ce code expose à des risques d’une mauvaise gestion de la mémoire, et rend plus difficile le déboggage.

Calcul du gris

void color::to_greyscale()
{
  // Compute the mean color
  unsigned int grey = red.value;
  grey += red.next_channel->value;
  grey += red.next_channel->next_channel->value;
  grey /= 3;

  // Update all channels
  red.value = grey;
  red.next_channel->value = grey;
  red.next_channel->next_channel->value = grey;
}

La mise à jour d’un pixel se fait via le déréférencement successif des valeurs référencées par chaque composante.

Table de hachage

Définition du type

struct color
{
  // Constructor
  color();

  // Convert image to greyscale
  void to_greyscale();

  // Channels data
  std::map<char, unsigned int> channels;
};

Question lisibilité on se trouve à peu de chose prés au même niveau que dans le cas du tableau d’entiers. Bienqu’il soit possible d’utiliser une implémentation indépendante, l’utilisation de la librairie standard C++ rendra plus difficile l’intégration de ce code dans le cas d’un programme embarqué par exemple.

Calcul du gris

void color::to_greyscale()
{
  // Compute the mean color
  unsigned int grey =
    (channels['r'] + channels['g'] + channels['b']) / 3;

  // Update all channels
  channels['r'] = grey;
  channels['g'] = grey;
  channels['b'] = grey;
}

En ce qui concerne les calculs, on regagne en facilité (pas en compléxité) d’accés en lecture/écriture de chaque composante.

Les performances

Le calcul des performance a été réalisé de la façon suivante : pour chaque type, une image de 1024x1024 pixels a été créée. La méthode to_greyscale() a été appelée 50 fois. Chaque appel de la méthode a été chronométré (en utilisant les fonctions dans <chrono>). Le temps indiqué est la moyenne des temps d’exécution sur les 50 appels.

Compilateur et machine

Le compilateur utilisé est GCC.

~$: gcc --version
gcc (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0

Les options de compilation utilisées sont -O2 -pedantic -Wall. Le code a tourné sur un processeur AMD E-450, 1.6 GHz.

Résultats

Type Temps d’exécution
Structure simple 12 ms
Tableau 9 ms
Champs de bits 13 ms
Liste chaînée 77 ms
Table de hachage 147 ms

Le code est disponible ici sur le dépot git.