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

Un livre de Wikilivres.
Contenu supprimé Contenu ajouté
Ligne 84 : Ligne 84 :
| prev=Les débordements de puissance de deux
| prev=Les débordements de puissance de deux
| prevText=Les débordements de puissance de deux
| prevText=Les débordements de puissance de deux
| next=L'échange de deux variables
| nextText=L'échange de deux variables
}}{{autocat}}
}}{{autocat}}

Version du 24 octobre 2021 à 23:40

Dans cette partie, nous allons voir ce qu'il est possible de faire avec l'instruction XOR. Faire un XOR entre deux nombres permet de faire pas mal de choses : émuler certaines instructions, échanger deux variables, fabriquer des listes chainées et j'en passe. Les techniques que nous allons voir 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.

Émulation d'instructions

L'instruction XOR permet d'émuler des opérations plus complexes, dont 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. Et il existe plusieurs méthodes pour mettre un registre à 0 en assembleur, certaines n'étant compatibles qu'avec certains processeurs. Certains processeurs ont une instruction dédiée pour mettre à 0 un registre : c'est alors la solution idéale dans la (quasi-)totalité des cas. Sur les processeurs qui n'ont pas cette instruction, on peut utiliser une instruction MOV (ou équivalent) pour mettre un 0 dans le registre. Mais il existe une dernière solution : faire un XOR entre le registre et lui-même.

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.

L'utilisation d'une opération XOR peut utiliser moins de cycles d'horloge et/ou économiser de la mémoire (encodage plus court). Si c'est le cas, elle est utilisée par les compilateurs pour mettre à zéro un registre. C'est notamment ce qui est fait sur les architectures x86, sur lesquelles cette solution est légèrement meilleure que celle utilisant un MOV. Sur ces architectures, il faut utiliser MOV en adressage immédiat, c'est à dire que la constante à mettre dans le registre est stockée dans l'instruction elle-même. Et cette constante prend facilement 8 à 16 bits, ce qui fait qu' l'instruction MOV est assez longue avec ce mode d'adressage. En comparaison, le XOR entre deux registre est très court car il suffit de préciser l'opcode et deux noms de registre eux-mêmes très courts (quelques bits). 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

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 !