« Les opérations bit à bit/Les subtilités du XOR » : différence entre les versions

Un livre de Wikilivres.
Contenu supprimé Contenu ajouté
Ligne 257 : Ligne 257 :


{{NavChapitre | book=Les opérations bit à bit
{{NavChapitre | book=Les opérations bit à bit
| prev=Manipulations sur les puissances de deux
| prev=Les débordements de puissance de deux
| prevText=Manipulations sur les puissances de deux
| prevText=Les débordements de puissance de deux
}}{{autocat}}
}}{{autocat}}

Version du 20 octobre 2021 à 19:58

Dans cette partie, nous allons voir ce qu'il est possible de faire avec l'instruction XOR. Les techniques suivantes se basent sur les propriétés de l'opération XOR. Pour rappel, nous avons vu au premier chapitre que : et . Ces deux propriétés sont assez importantes, surtout la dernière. La première méthode a été utilisée dans le chapitre sur les masques, aussi nous ne l'utiliserons pas plus.

Émulation d'instructions

L'instruction XOR permet parfois d'émuler certaines opérations plus complexes. Elle permet notamment d'émuler des transferts entre registres/variables ou des initialisations de registres. Son utilisation première est notamment la mise à 0 d'un registre ou d'une variable.

Mise à zéro rapide

Initialiser une variable à 0 est une opération extrêmement courante. En conséquence, il vaut mieux la rendre la plus rapide possible. S'il n'y a pas de méthode particulière pour cela dans les langages de haut-niveau, les compilateurs ont cependant quelques possibilités d'optimisation. Il y a en effet plusieurs méthodes pour mettre un registre à 0 en assembleur, certaines n'étant compatibles qu'avec certains processeurs. Certains processeurs ont une instruction machine pour mettre à 0 un registre : c'est alors la solution idéale dans la (quasi-)totalité des cas. Sur les architectures n'ayant pas cette instruction, on peut utiliser une instruction MOV (ou équivalent). On peut l'utiliser en adressage immédiat : on place alors la constante 0 dans le registre. D'autres processeurs possèdent un registre spécial, qui conserve en permanence la valeur 0 et n'est pas accessible en écriture. Il suffit alors d'effectuer un MOV de ce registre vers le registre à initialiser. Mais il existe une dernière solution : faire un XOR entre le registre et lui-même. C'est notamment ce qui est fait sur les architectures x86. L'utilisation d'une opération XOR peut permettre d'utiliser moins de cycles d'horloge (exécution plus rapide) et/ou économiser de la mémoire (encodage plus court).

Pour comprendre pourquoi cette solution fonctionne, il faut rappeler que l'on obtient 0 lorsque l'on XOR un bit avec lui-même : . Si cela vaut pour un bit, cela s'applique aussi quand on effectue un XOR bit à bit sur un nombre : chacun de ses bits sera donc XORé avec lui-même, ce qui fait qu'un nombre XOR lui-même donnera toujours zéro. Cette propriété est utilisé par les compilateurs pour mettre à zéro un registre. Sur les architectures x86, cette solution est légèrement meilleure que celle utilisant un MOV avec adressage immédiat. Cette dernière solution demande d'intégrer une constante en adressage immédiat, qui prend facilement 8 à 16 bits. Un nom de registre est beaucoup plus court, ce qui fait que la solution avec un XOR donne des instructions plus petites. Sur d'autres processeurs, qui ne supportent pas l'adressage immédiat, la constante est lue depuis la mémoire. En comparaison, un XOR entre deux registres ne va rien charger en RAM et est donc plus rapide.

Émulation du MOV

L'opération XOR permet d'émuler une instruction MOV sur les processeurs qui n'en disposent pas, comme certains processeurs MIPS. Le MOV est alors remplacé par une opération XOR à trois adresses, qui XOR le registre à copier avec zéro, et place le résultat dans le registre de destination. L'opération est d'autant plus utile que ces processeurs disposent d'un registre en lecture seule, qui contient toujours la valeur 0.

Pour résumer : MOV Ra -> Rb <=> Ra XOR 0 -> Rb

Échange de deux variables

Si je vous demande d'échanger le contenu de deux entiers a et b, vous allez certainement écrire un code similaire à celui-ci :

int t = a ;
a = b ;
b = t ;

Mais il est possible d'échanger les valeurs de deux registres/variables, sans utiliser de registre/variable temporaire ! Pour cela, il existe différentes méthodes assez simples.

Échange par addition et soustraction

La première méthode alternative qui utilise des additions et soustractions. Il faut effectuer ces opérations dans l'ordre suivant :

  •  ;
  • ;
  • .

Pour comprendre plus profondément pourquoi cette méthode marche, il suffit de regarder à chaque étape ce que l'on trouve dans A et dans B.

Etape Variable A Variable B
Avant l'opération A B
Première étape A - B B
Seconde étape A - B B + (A - B) = A
Troisième étape A - (A - B) = B A

Cependant, il y a un risque de débordement au niveau de l'addition, qui fait que cette technique fonctionne mal avec certaines opérandes. Cette technique utilise de plus des opérations arithmétiques, qui sont plus lentes que les opérations logiques sur de nombreux processeurs. Bref : il n'y a pas vraiment de raisons d'utiliser cette méthode.

Stupid XOR trick

Stupid XOR Trick.

Une autre méthode se base sur les particularités de l'instruction XOR et est connue sous le nom de stupid XOR trick chez nos amis anglo-saxons. Il est en effet possible d'échanger le contenu de deux registres/variables A et B en effectuant les opérations suivante, dans l'ordre :

  •  ;
  • ;
  • .

Le code source correspondant, en C/C++, est un joli oneliner :

x ^= y ^= x ^= y ;

Toutefois il faut faire attention quand les variables utilisées sont indirectement accédées à travers des pointeurs ou des références : il ne faut pas que les deux pointeurs ou références pointent la même adresse mémoire, sinon le contenu est perdu et remis à zéro.

Cette technique est utilisée comme optimisation, notamment en assembleur, car les anciens processeurs effectuent les opérations arithmétiques, comme l'addition et la soustraction utilisées par la première technique, plus lentement que les opérations binaires comme le XOR utilisé par la deuxième. Tandis que les processeurs modernes peuvent échanger facilement deux registres très rapidement par simple renommage, en une seule instruction pouvant prendre un seul cycle d'horloge. En conséquence, cette méthode reste utile sur les anciens processeurs et les petits microcontrôleurs qui ne possèdent que très peu de registres, dans un objectif d'optimisation certain.

Le fonctionnement de cette méthode se base sur le fait que . Faites les calculs vous-même : . Pour comprendre plus profondément pourquoi cette méthode marche, il suffit de regarder à chaque étape ce que l'on trouve dans A et dans B.

Etape Variable A Variable B
Avant l'opération A B
Première étape B
Seconde étape
Troisième étape

Vu que XOR est associatif, commutatif, distributif, on peut alors supprimer les parenthèses. Les identités et nous permettent alors de simplifier fortement les expressions. Les simplifications précédentes donnent :

Etape Variable A Variable B
Avant l'opération A B
Première étape B
Seconde étape
Troisième étape

Ce qui se simplifie en :

Etape Variable A Variable B
Avant l'opération A B
Première étape B
Seconde étape A
Troisième étape B A

Comme on le voit, nos données ont bien étés inversées.

Piège avec les pointeurs

Il faut faire attention au cas où des pointeurs sont utilisés pour adresser indirectement les variables à échanger, par exemple si une fonction est créée pour procéder à l'échange, comme celle-ci :

void exchangeIntegers(int* px, int* py)
{
    *px ^= *py ^= *px ^= *py ;
}

Dans le cas où l'on passe deux adresses différentes, aucun problème ne se produit :

int a = 5;
int b = 10;
exchangeIntegers(&a, &b);

Dans le cas où l'on passe deux adresses identiques, la variable pointée est remise à zéro (a XOR a) :

int a = 5;
exchangeIntegers(&a, &a);

Ce n'est pas l'effet voulu par la fonction. Il faut donc soit utiliser la technique de la variable intermédiaire qui n'a pas ce problème, soit vérifier les adresses passées en paramètres.

void exchangeIntegers(int* px, int* py)
{
    if (px != py) *px ^= *py ^= *px ^= *py ;
}

Listes chainées XOR

La propriété est aussi utilisée pour créer des listes doublement chainées plus économes en mémoire que la normale, appelées listes liées par ou exclusif, ou encore listes à chaînage XOR.

Une liste doublement chainée normale stocke deux pointeurs : un vers le nœud suivant, et un vers le nœud précédent.

Liste doublement chainée normale.

Les listes liées par ou exclusif (XOR linked lists) permettent de stocker une valeur au lieu de deux pointeurs. Cette valeur contient le XOR entre le pointeur vers le prochain nœud, et le pointeur vers le nœud précédent. Son type doit donc être capable de stocker une adresse complète (64 bits sur les architectures 64 bits). Ainsi, on gagne un petit peu de mémoire : au lieu de stocker deux adresses, on n'en stocke plus qu'une. La consommation mémoire est identique à celle d'une liste simplement chainée.

Liste chainée XOR.

Le pointeur sur le nœud précédent sera noté P (pour Précédent), le pointeur contenu dans le nœud actuellement visité est appelé Ptr et le pointeur vers le nœud suivant sera noté S (pour Suivant). Pour rappel, le pointeur stocké dans le nœud est égal au XOR entre P et S : .

Parcourir une liste liée par ou exclusif

Traverser une liste à chaînage XOR ne se fait que dans un sens : soit on part du début pour arriver à la fin, soit on va dans l'autre sens.

Le parcours commençant par le début de la liste vers la fin de celle-ci s'effectue en gardant deux pointeurs. Obtenir le pointeur vers le prochain élément demande de faire un XOR entre le pointeur vers l’élément précédent avec le pointeur stocké dans le nœud. En effet, on a :

.

Le même raisonnement peut s'adapter dans le cas où l'on parcours la liste dans l'autre sens, si ce n'est qu'il faut effectuer un ou exclusif entre le pointeur de l’élément précédemment visité et le pointeur stocké dans le nœud.

Il existe des listes à chaînage XOR qui permettent le parcours dans les deux sens. De telles listes doivent maintenir un pointeur vers le premier élément et un pointeur vers le dernier. Quand la liste est vide, les deux pointeurs valent NULL / NIL (adresse 0). Quand la liste ne contient qu'un élément, les deux pointeurs pointent le même élément.

Algorithme de parcours des éléments d'une liste du début vers la fin :

prev = 0
pelem = list.first
// ... traiter l'élément pointé par pelem ...

// Élément suivant
p = pelem
pelem = prev ^ list.ptr
prev = p
// si pelem == 0 alors le parcours est terminé

Algorithme de parcours des éléments d'une liste de la fin vers le début :

next = 0
pelem = list.last
// ... traiter l'élément pointé par pelem ...

// Élément précédent
p = pelem
pelem = next ^ list.ptr
next = p
// si pelem == 0 alors le parcours est terminé

Les 2 différences entre les 2 parcours sont le nom de la variable (prev / next) stockant l'ancienne valeur du pointeur pelem, et l'initialisation de ce pointeur (list.first / list.last).

Les avantages et inconvénients des listes chaînées liées par ou exclusif

Les listes à chaînage XOR ne sont pas une structure de données sans inconvénients.

Le premier inconvénient est le fait que l'on doive parcourir la liste à chaînage XOR du début à la fin. Impossible de revenir en arrière pendant le parcours de la liste chaînée, à moins d'utiliser une variable supplémentaire. Certes, revenir en arrière de cette manière est assez rare, mais cela peut avoir son utilité.

D'autres avantages des listes doublements chaînées normales ne sont pas possibles avec les listes à chaînage XOR. Par exemple, il est impossible de retirer un élément de la liste si on ne connaît que son adresse. Il est aussi impossible d'insérer un élément avant/après un élément repère si on ne connaît que l'adresse du dit repère. Pour simplifier, les insertions et retraits demandent de parcourir toute la liste, sauf dans quelques cas assez rares. Ce n'est pas le cas sur les listes sans chaînage XOR. Les 2 inconvénients sur le parcours en sens inverse et la modification à partir d'un pointeur sont résolus en utilisant deux pointeurs : un sur l'élément et un autre sur le suivant ou le précédent de l'élément pointé.

Enfin, de telles listes marchent assez mal avec les algorithmes de ramasse-miettes (garbage collection). Avec l'usage d'un ramasse-miette, la libération de la mémoire inutilisée est automatique. Les algorithmes de libération de la mémoire inutilisée se basent cependant sur l'usage de pointeurs. Pour être précis, ils analysent les données accessibles via les pointeurs d'un programmes pour déterminer la mémoire utile de la mémoire libre. Si les pointeurs sont cachées, comme dans les listes à chaînage XOR, les algorithmes ne peuvent pas les reconnaître et donnent un résultat sous-optimal. La libération de la mémoire se fait moins bien, ce qui peut poser quelques problèmes. Ironiquement, cela peut augmenter la consommation mémoire alors que le but du chaînage XOR est justement de la réduire. Cependant les langages à ramasse-miette n'autorisent pas les pointeurs, car le langage les gère. Il n'est donc pas possible d'implémenter de telles listes dans ces langages. Par contre, le problème peut se poser si un ramasse-miette est utilisé dans un langage qui n'en utilise pas nativement. En effet, il existe des librairies qui permettent d'ajouter un ramasse-miette dans des programmes codés en C ou en C++, un bon exemple étant le Boehm garbage collector. L'usage d'une liste à chaînage XOR avec de telles librairies peut éventuellement poser quelques problèmes.

Ces inconvénients sont à comparer avec les avantages promis pour cette structure de données. L'avantage principal d'une liste à chaînage XOR est évidemment la consommation de mémoire, sa consommation mémoire étant équivalente à celle d'une liste chaînée simple. L'économie en mémoire est d'un pointeur par élément de la liste. Le gain et surtout sensible sur les longues listes chaînées, sur les machines où les pointeurs sont longs, et où chaque élément a une petite taille. Plus les éléments de la liste sont gros, moins l'économie est importante, rapportée au poids total de la liste chaînée. De plus, il existe d'autres solutions pour économiser de la mémoire avec les listes chaînées, comme les listes chaînées déroulées (unrolled linked lists). Tout cela expliquer que le chaînage XOR est actuellement peu , voire pas du tout, utilisé sur les machines modernes. Il peut éventuellement être utile sur quelques machines embarquées très peu puissantes, et encore !