David Madore's WebLog: Comment les processeurs se partagent la mémoire

[Index of all entries / Index de toutes les entréesLatest entries / Dernières entréesXML (RSS 1.0) • Recent comments / Commentaires récents]

↓Entry #1654 [older| permalink|newer] / ↓Entrée #1654 [précédente| permalien|suivante] ↓

(mardi)

Comment les processeurs se partagent la mémoire

Ça fait partie de ces questions dont je me suis souvent dit un jour, j'essaierai de comprendre ça : comment, sur les ordinateurs modernes — dont le moindre PC a maintenant deux ou quatre cœurs travaillant en parallèle —, l'accès à la mémoire est-il présenté aux processeurs ? Quelle est la sémantique des opérations ?

La vision idéale, ce serait de croire que quand un processeur écrit une donnée en mémoire, cette donnée peut instantanément être lue par tous les processeurs (lui-même comme les autres) : autrement dit, si un processeur écrit une valeur v à l'emplacement x à l'instant t et qu'un autre processeur, à un instant t′>t, lit la valeur à cet emplacement x, alors c'est bien v qui sera lue (sauf si une autre écriture a eu lieu entre temps, bien sûr) — c'est la vision bien rassurante d'un monde totalement ordonné par le temps, et on appelle ça le modèle de cohérence stricte (ou strict, parce que c'est peut-être le modèle qui l'est). En réalité, le temps, on ne sait pas vraiment ce que c'est, l'exécution d'une instruction par un processeur est quelque chose qui est étalée sur un long intervalle, donc ça n'a pas vraiment de sens, mais on peut au moins proposer une vision abstraite de la même chose, le modèle de cohérence séquentielle (Lamport, 1979) : la mémoire est dite séquentiellement cohérente par rapport à un système multiprocesseur lorsqu'il existe une façon d'ordonner totalement les instructions exécutées par tous les processeurs de façon cohérente avec l'ordre d'exécution (l'ordre de programme) sur chaque processeur séparément, et de façon que chaque lecture en mémoire (par n'importe quel processeur) renvoie le résultat de la plus récente écriture — pour cet ordre total — qui y a été faite (par n'importe quel processeur, le même ou un autre). Autrement dit, la cohérence séquentielle signifie qu'on peut s'imaginer que les instructions exécutées par les différents processeurs s'exécutent dans un certain ordre global (elles ne sont jamais exactement simultanées, bien sûr), et tout se passe comme si les accès à la mémoire étaient faits par rapport à cette sérialisation. Si on n'a jamais réfléchi à la question, ça peut paraître tellement évident qu'on n'y pense même pas.

Par exemple, si un processeur écrit successivement 1 dans des emplacements mémoire x et y qui valaient initialement 0 (je noterai ça [x]←1; [y]←1) et qu'un autre processeur, pendant ce temps, lit l'emplacement y dans un registre r1 (je noterai ça r1←[y]) et y trouve la valeur 1, alors nécessairement s'il lit ensuite l'emplacement x il trouvera la valeur 1, n'est-ce pas ? N'est-ce pas ? Cela semble presque évident : quelqu'un écrit x puis y dans cet ordre, et qu'un d'autre arrive à un moment où la valeur de y a été écrite, donc forcément celle de x a dû l'être aussi. Dans le modèle de cohérence séquentielle, ce raisonnement est juste : ([x]←1; [y]←1 | r1←[y]; r2←[x]) — si la barre verticale | indique une exécution en parallèle par deux processeurs en partant d'une mémoire initialisée à zéro — ne peut jamais produire le résultat r1=1, r2=0 (alors que les cas de figure r1=0, r2=0 et r1=1, r2=1 et r1=0, r2=1 sont possibles selon que les lectures ont lieu respectivement complètement avant, complètement après, ou quelque part entre les deux, par rapport aux écritures).

Pourtant, dans un vrai système, les chose ne sont pas forcément aussi simples : les accès de chaque processeur à la mémoire peuvent être réordonnés (et même si c'est fait de façon qui, sur chaque processeur, ne modifie pas sa sémantique locale — c'est-à-dire, si on ne réordonne deux accès à la même cellule que si ce sont deux lectures, cela change la façon dont les autres processeurs verront se dérouler les événements), des données peuvent être rendues visibles plus ou moins vite à d'autres processeurs à cause du fait qu'elles sont détenues par une ligne de cache locale, etc. Certains de ces phénomènes sont rendus invisibles au niveau sémantique, c'est-à-dire que le programme ne peut pas s'apercevoir qu'ils se produisent : on espère en tout cas que c'est le cas sur un système à un seul processeur (on peut réordonner lectures et écritures, mais doit maintenir l'illusion qu'elles se déroulent exactement dans l'ordre où elles sont programmées). Mais sur un système multiprocesseur, on va vraiment pouvoir exhiber des problèmes, où par problèmes j'entends des différences de comportement par rapport au modèle de cohérence séquentielle.

Un exemple de problème concevable est celui que je viens de donner : [x]←1; [y]←1 | 1←[y]; 0←[x] (cette fois je ne note pas les registres, car on se moque bien de ce qu'ils sont, donc j'écris juste 1←[y] pour dire que le processeur a lu 1 depuis y), autrement dit, qu'un processeur écrive dans des emplacements mémoire x et y dans cet ordre, et qu'un autre voie la valeur écrite dans y alors qu'il ne voit pas encore la valeur écrite dans x ; ceci peut effectivement se produire sur certaines familles de processeurs : par exemple, il est réellement possible sur les PowerPC, les Alpha et les Itanium, et il est autorisé par certains des modèles mémoires des Sparc (PSO et RMO, même si le modèle obligatoire, TSO, n'admet pas ce comportement). Sur le modèle des Intel x86, ([x]←1; [y]←1 | 1←[y]; 0←[x]) est garanti ne pas se produire — le modèle Intel est assez strict et relativement proche de la cohérence séquentielle ; mais même sur ce modèle, des problèmes différents peuvent se produire : par exemple, ([x]←1; 0←[y] | [y]←1; 0←[x]) est possible sur n'importe quel système multiprocesseur moderne (ou même pas moderne), et il contredit également la séquentialité (si le premier processeur voit 0 dans y, avec la cohérence séquentielle ça implique que l'écriture de 1 dans y par l'autre processeur n'a pas été faite, donc la lecture de x par lui doit être aussi postérieure, et elle doit nécessairement renvoyer 1 et pas 0).

Comme on le voit par mes deux exemples précédents, il y a un certain nombre de sortes de problèmes différentes. Dans un espoir de les classifier, on peut imposer des garanties pour éviter les problèmes, c'est-à-dire, des règles qui spécifient la façon dont, essentiellement, les processeurs voient chacun les écritures des autres : ce sont les modèles de cohérence mémoire, plus ou moins stricts (i.e., proches de la cohérence séquentielle). Et c'est là que ça devient compliqué, et un peu confus dans mon esprit : parce que toutes sortes de modèles de cohérence ont été définis dans la littérature, avec malheureusement des notations ou des approches très différentes et, pire, parfois un même terme qui recouvre des notions différentes selon l'auteur (ce qui donne des affirmations en apparence contradictoires[#]) — on ne s'y retrouve pas du tout.

Je peux quand même essayer de décrire ce que j'ai compris (attention, ce qui suit devient ultra-technique). Il s'agit, donc, de modèles qui offrent des garanties intermédiaires entre la cohérence séquentielle (la plus forte) et la cohérence locale (Bataller & Bernabéu, 1997), très faible. Cette cohérence locale demande seulement que chaque processeur voie ses propres écritures se dérouler dans l'ordre où il les a exécutées, les écritures faites par les autres processeurs pouvant intervenir, de son point de vue, dans n'importe quel ordre (a priori variable selon le processeur observateur q et le processeur écrivant p ; notez quand même qu'on n'autorise pas à une écriture par un processeur p d'être vue plusieurs fois par un autre processeur q, donc ce n'est toujours pas la notion la plus faible imaginable) : si on veut, on peut s'imaginer que chaque processeur a une copie locale de la mémoire, sur laquelle il effectue immédiatement ses opérations, et quand il fait une écriture il la signale aux autres processeurs pour qu'elle soit aussi exécutée dans leur copie de la mémoire, mais ce message peut prendre un temps quelconque à arriver à chaque autre processeur, les messages peuvent se croiser, ils peuvent arriver dans des ordres différents pour un processeur et pour un autre, etc. : essentiellement rien n'est garanti (si ce n'est qu'il n'y a aura pas de duplication des messages).

À peine plus forte que la cohérence locale, on a la cohérence lente (Hutto & Ahamad, 1990), qui spécifie que, en outre, pour chaque cellule mémoire x donnée et chaque processeur p observé, chaque processeur observateur q voit les écritures vers x par p se dérouler dans l'ordre dans lequel elles ont été programmées par p (en revanche, on ne fait pas d'hypothèse sur l'entrelacement de ces ordres si on fait varier x ou p, ni sur la différence de ces entrelacements si on fait varier q) : dans mon modèle par messages, cela signifie que plusieurs messages d'un même processeur p à un même processeur q et concernant une même cellule x ne peuvent pas se croiser. La cohérence de cache[#2] (Goodman, 1989), est encore un chouïa plus forte que la cohérence lente (et a fortiori que la cohérence locale), et elle exige que pour chaque cellule mémoire x tous les processeurs voient les écritures vers x se dérouler dans le même ordre (ordre qui doit être cohérent avec l'ordre programmé par n'importe quel processeur p qui écrirait sur cette cellule), mais sans faire d'hypothèse sur la façon dont ces ordres s'entrelacent entre cellules différentes. La cohérence PRAM (Lipton & Sandberg, 1988) renforce la cohérence lente de façon à peu près orthogonale à la cohérence de cache en demandant, cette fois, que chaque processeur q voie les écritures vers l'ensemble de la mémoire effectués par chaque processeur p dans l'ordre dans lequel elles ont été programmées par p, mais sans faire d'hypothèse sur la façon dont ces ordres s'entrelacent entre des p différents : là on commence à atteindre des conditions qui ne sont plus vérifiées sur les processeurs réels. La cohérence de processeur au sens de Goodman (Goodman, op. cit.) demande en gros qu'on ait à la fois cohérence de cache et cohérence PRAM, même si on peut faire des petites variations autour de la question selon qu'on demande que les ordres sur les écritures sur les cellules mémoire x et les ordres sur les écritures par les processeurs p doivent ou non s'entrelacer de la même façon sur chaque processeur q — il faut admettre que c'est un peu de l'enculage de mouches. La cohérence causale (Ahamad &al, 1991, cf. par exemple Ahamad &al, 1994), qui renforce la cohérence PRAM mais toujours orthogonalement à la cohérence de cache, est un petit peu fastidieuse à définir, mais elle demande quelque chose de très logique et raisonnable : en gros, si un processeur voit une écriture w en mémoire, alors il voit aussi toute écriture qui la précède causalement (i.e., qui pourrait l'avoir influencée) — c'est-à-dire, essentiellement, toute écriture w′ ayant fixé le résultat d'une lecture r ayant précédé l'écriture w en mémoire (et ainsi de suite par transitivité) ; par exemple, si un processeur fait [x]←1 (écriture w′) et qu'un deuxième lit 1←[x] (lecture r) puis fait [y]←1 (écriture w), alors tout processeur qui observe 1←[y] doit aussi observer 1←[x] (il a vu une conséquence possible w de l'écriture w′:[x]←1, donc il doit voir cette écriture elle-même) ; ceci est programmatiquement utile car cela signifie que si un processeur exécute du code comme if (x==1) y←1 alors les autres processeurs verront une vision cohérente du monde. Je crois que je comprends à peu près ces différents modèles, qui sont d'ailleurs assez bien récapitulés dans Steinke & Nutt, 2004 et dans Kawash, 2000 (disponible ici).

Là où ça devient nettement plus confus (pour moi), c'est que si les modèles dont je viens d'esquisser l'idée définissent les choses en termes de vues des instructions d'un processeur par les autres processeurs, il y en a aussi, qui les définissent en termes de réordonnancement des instructions (et là, même si ce sont des conditions plus ou moins orthogonales, le rapport précis avec les notions précédentes n'est pas clair du tout) : cf. Adve & Gharachorloo, 1995 pour une présentation assez synthétique et plus ou moins claire selon cet ordre d'idées, beaucoup moins théorique que les autres papiers que j'ai cités. Par exemple, on peut définir un ordre partiel sur les opérations d'un programme, qui est l'ordre du programme normal sauf qu'une lecture mémoire qui suit une écriture mémoire à un emplacement différent n'est pas (a priori) comparable à elle (elle le sera quand même si entre les deux il y a une instruction qui force l'ordre par transitivité) ; autrement dit, cet ordre permet d'anticiper une lecture en la faisant passer avant des écritures vers des cellules mémoires différentes. Lorsqu'on peut mettre un ordre total sur toutes les opérations écritures de façon que, pour chaque processeur, il y ait moyen d'intercaler ses opérations de lecture de façon à donner une histoire correcte et compatible avec l'ordre que je viens de définir, on dit qu'on a la contrainte de cohérence TSO au sens de Kohli (Kohli &al, 1993). En clair, on permet de réordonner les instructions de tous les processeurs mais seulement en faisant passer des lectures avant des écritures vers des emplacements différents, et on doit arriver comme ça à une sérialisation. Cette cohérence TSO-Kohli est plus forte que la cohérence de cache (tout simplement car on ne permet pas de réordonner les instructions touchant à la même cellule mémoire), mais incomparable à la fois avec la cohérence de processeur au sens de Goodman, avec la cohérence causale, et avec la cohérence PRAM. La « vraie » cohérence TSO (Sun Microsystems, 1991), elle, permet aussi de réordonner deux lectures (depuis des cellules différentes) à condition que la première soit « domestique », c'est-à-dire qu'elle produit le résultat d'une écriture par le même processeur : concrètement, cela signifie qu'un processeur peut voir les résultats de ses propres écritures avant qu'elles soient visibles par les autres (les explications sont dans Kawash, op. cit., §4.2.2). La cohérence PSO (Sun Microsystems, op. cit.) est encore plus faible et permet aussi de réordonner deux écritures (vers des cellules différentes) ; et la cohérence RMO (Sun Microsystems, 1994) permet de réordonner deux accès quelconques sauf quand c'est indispensable pour préserver la sémantique locale (deux accès à la même cellule dont une au moins est une écriture). Mais ces différents ordres partiels — ou règles de réordonnancement — permettent aussi, j'ai l'impression, plutôt que de chercher une sérialisation complète qui les respecte (c'est-à-dire, finalement, donner une version faible de la cohérence séquentielle), de définir des versions faibles des autres systèmes de cohérence que j'ai définis plus haut (cohérence PRAM / cohérence de processeur, et cohérence causale surtout, car a priori pour la cohérence de cache ça ne devrait rien changer vu qu'on ne permet pas de réordonner des écritures vers une même cellule mémoire). Cela devrait, par exemple, recouvrir la cohérence de processeur au sens DASH (Lenoski &al, 1990), qui est subtilement différente de celle de Goodman évoquée plus haut, mais Ahamad &al, 1993 n'est pas d'accord avec moi, et de toute façon (voir ci-dessous) il y a l'air d'avoir des contradictions gênantes dans la littérature. Je ne sais pas trop quoi penser.

Et encore ! Tout ce fourbi ne concerne que les modèles de cohérence à mémoire pure, où les seules instructions sont des accès, en lecture ou en écriture, à la mémoire (et en plus, je considère la mémoire comme un tableau de cellules atomiques : je ne parle pas de la question des accès non alignés, par exemple). Dans les modèles hybrides, il y a, en outre, des instructions de synchronisation, qui permettent d'obtenir des contraintes de cohérence additionnelles que le modèle de base (pur) ne promet pas, ce qui est indispensable si ce modèle de base est trop faible. Hélas, ces instructions sont assez variées et il n'est pas évident de comparer les modèles différents. En pratique, cependant, les processeurs utilisent généralement le modèle des barrières mémoire : dans la description des modèles de cohérence en termes de réordonnancement, la barrière mémoire offre la garantie que les instructions qui la précèdent ne seront pas réordonnées avec celles qui la suivent (éventuellement en se limitant aux instructions d'un certain type : le Sparc (en modèle RMO), par exemple, fournit quinze types de barrières mémoire pour offrir toutes les combinaisons de protection de réordonnancement des lectures avant contre lectures après la barrière, lectures avant contre écritures après, écritures avant contre lectures après, et écritures avant contre écritures après ; l'Alpha ne fournit qu'une barrière générale et une barrière en écriture ; l'Itanium fournit des annotations d'acquisition/libération qui indiquent, plutôt, dans quel sens on peut réordonner par rapport à ces instructions annotées (et aussi une barrière mémoire équivalente à une acquisition+libération). Les détails dépendent au plus haut point du modèle hybride utilisé.

Pour essayer de m'éclaircir les idées, au moins pour les modèles purs (i.e., sans aucune synchronisation), j'ai essayé de dresser un tableau de certains problèmes pouvant apparaître, et de quels modèles purs les autorisent : dans tout ce qui suit, toutes les cellules mémoire valent initialement 0, les lettres indiquent évidemment des cellules différentes, la barre indique la séparation entre processeurs différents (et il n'y a pas d'autre processeur qui tourne sur les cellules concernées), et les points-virgules indiquent l'ordre de programme sur un processeur. Un point (•) dans le tableau indique que la trace d'exécution figurant au début de la ligne est possible (i.e., que le « problème » peut survenir) dans le modèle décrit par la colonne.

SéqnKtsoStsoSpsoSrmoPrdaCausProcPramCachLentLoclIx86
[x]←1 | 1←[x]; 0←[x]
[x]←1; [x]←2 | 2←[x]; 1←[x]
[x]←1; [y]←1 | 1←[y]; 0←[x]
[x]←1; [y]←2; [x]←2 | [y]←1; 0←[x]; 2←[x]; 1←[y] ???
1←[x]; [y]←1 | 1←[y]; [x]←1
[x]←1 | 1←[x]; [y]←1 | 1←[y]; 0←[x]
[x]←1; 2←[x] | [x]←2; 1←[x]
[x]←1 | [y]←1 | 1←[x]; 0←[y] | 1←[y]; 0←[x]
[x]←1; [y]←2; 1←[y] | [y]←1; [x]←2; 1←[x]
[x]←1; [z]←1; 0←[y] | [y]←1; [z]←2; 0←[x]
[x]←1; 1←[x]; 0←[y] | [y]←1; 1←[y]; 0←[x]
[x]←1; 0←[y] | [y]←1; 0←[x]
[x]←1; [y]←2; 1←[x] | 1←[x]; [x]←2; [y]←1; 2←[y]
[x]←1; [y]←2; 2←[y]; 1←[x] | 1←[x]; [x]←2; [y]←1; 2←[y] •?

Sigles : Séqn = cohérence séquentielle ; Ktso = cohérence TSO au sens de Kohli ; Stso = cohérence (Sparc) TSO ; Spso = cohérence (Sparc) PSO ; Srmo = cohérence (Sparc) RMO ; Prda = cohérence de processeur au sens DASH (telle que je la comprends dans, par exemple, Adve & Gharachorloo, 1995 : ceci contredit explicitement Ahamad &al, 1990) ; Caus = cohérence causale ; Proc = cohérence de processeur au sens de Goodman ; Pram = cohérence PRAM ; Cach = cohérence de cache ; Lent = cohérence lente ; Locl = cohérence locale ; Ix86 = comportement du Intel x86.

Je n'ai pas mis les cohérences au sens PowerPC, Alpha et Itanium dans le tableau, car pour autant que je les comprends elles doivent coïncider avec la cohérence Sparc RMO (colonne Srmo du tableau) en l'absence de barrières mémoires : par exemple, dans la ligne ([x]←1 | [y]←1 | 1←[x]; 0←[y] | 1←[y]; 0←[x]), le comportement est indiqué comme possible sur Srmo, mais c'est car le modèle permet la permutation des deux lectures — si on place une barrière lecture-contre-lecture entre toutes les instructions de lecture (à l'emplacement des points-virgules, donc), le comportement devient impossible sur Sparc où les écritures sont atomiquement visible aux autres processeurs, alors qu'il reste, je crois, possible sur PowerPC où un processeur peut lire en avance les écritures d'un autre processeur.

[#] Exemple d'incohérence : Ahamad &al, 1990 explique que la cohérence de processeur, que ce soit au sens de Goodman ou au sens DASH, implique la cohérence PRAM ; Kohli &al, 1993 est d'accord avec ce fait, et ajoute, en outre, que la cohérence TSO implique la cohérence de processeur (et donc la cohérence PRAM), ce que Adve & Gharachorloo, 1995 a également l'air de penser ; or Kawash, 2000 non seulement explique que la cohérence TSO au sens du papier de Kohli ci-dessus n'est pas la vraie cohérence TSO, mais par ailleurs donne un contre-exemple incontestable (computation 11, page 71) vérifiant la cohérence TSO dans les deux sens (la « vraie » et celle de Kohli) mais pas la cohérence PRAM. Contradiction : l'informatique disparaît dans un pouf de logique !

[#2] En anglais on peut dire coherence tout seul, parce que le mot que je traduis par cohérence en français est consistency.

↑Entry #1654 [older| permalink|newer] / ↑Entrée #1654 [précédente| permalien|suivante] ↑

[Index of all entries / Index de toutes les entréesLatest entries / Dernières entréesXML (RSS 1.0) • Recent comments / Commentaires récents]