Poly Archi2022
Poly Archi2022
Poly Archi2022
Les objectifs de ce cours sont d’une part de faire le lien entre la couche logicielle (ici très
rudimentaire) et la couche matérielle, et d’autre part d’introduire à de l’informatique bas-niveau.
Nous nous concentrerons principalement sur la microarchitecture des processeurs de la famille
Intel x86, c’est à dire la manière dont ces processeurs implémentent leur langage machine. Si un
ou plusieurs des termes que l’on vient d’utiliser vous sont inconnus en début de cours, c’est tout
à fait normal.
3
L’évaluation comportera une partie de contrôle continu (participation en TP et rendus de
TP) et une partie d’examen individuel final (écrit ou oral).
Sources complémentaires. Pour approfondir les sujets abordés dans ce cours, citons :
• Le cours de Florent de Dinechin à l’ENS Lyon
http://perso.citi-lab.fr/fdedinec/enseignement/2019/ASR1/polyENS.pdf
https://www.agner.org/optimize/
4
Table des matières
1 Du circuit à l’arithmétique 7
1.1 Circuits (pour la culture) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.1.1 Binaire et Booléen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.1.2 Circuit additionneur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
1.1.3 Et pour quelques circuits de plus... . . . . . . . . . . . . . . . . . . . . . . 10
1.2 Calcul entier . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
1.2.1 L’arithmétique est finie . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
1.2.2 Entiers signés et non signés . . . . . . . . . . . . . . . . . . . . . . . . . . 11
1.2.3 Le typage est une vue de l’esprit (ou du compilateur) . . . . . . . . . . . . 12
1.3 Encodage de nombres à virgule . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
1.3.1 Nombres représentables . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
1.3.2 Virgule fixe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
1.3.3 Notation scientifique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
1.4 Norme IEEE 754 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
1.4.1 Des formats . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
1.4.2 Décodage (cas général) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
1.4.3 Exercices . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
3 Hiérarchie mémoire 27
3.1 Problématique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
3.2 Principe d’un cache . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
3.3 Cache dans un ordinateur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
3.3.1 Blocs et ligne de cache . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
3.3.2 Adresse et numéro de bloc. . . . . . . . . . . . . . . . . . . . . . . . . . . 29
3.3.3 Pagination complètement associative . . . . . . . . . . . . . . . . . . . . . 30
3.4 Hiérarchie mémoire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
3.5 Associativité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
5
3.6 Exercice optionnel : transposition rapide de matrice . . . . . . . . . . . . . . . . . 35
6 Spectre et meltdown 55
6.1 Quelques notions de sécurité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55
6.2 Principes de Spectre et Meltdown . . . . . . . . . . . . . . . . . . . . . . . . . . . 55
6.2.1 Idée 1 : l’impunité de la spéculation . . . . . . . . . . . . . . . . . . . . . 56
6.2.2 Idée 2 : la spéculation erronée laisse des traces . . . . . . . . . . . . . . . 56
6.2.3 On combine le tout . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
6.3 Dans le détail : des soucis et des outils . . . . . . . . . . . . . . . . . . . . . . . . 57
6.4 Exercices . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59
6.5 Pour aller plus loin . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 60
7 Introduction à la vectorisation 63
7.1 Des parallelismes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
7.2 Vectorisation d’algorithme : un exemple . . . . . . . . . . . . . . . . . . . . . . . 64
7.3 Vectorisation de boucles par gcc . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
7.4 Instructions vectorielles en assembleur . . . . . . . . . . . . . . . . . . . . . . . . 66
7.5 Exercices . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
6
Chapitre 1
Du circuit à l’arithmétique
Cela devrait mettre en évidence que le calcul en nombres à virgule flottante (on dira simplement
flottants) est imprécis et, peut-être, surprenant. Cela peut avoir des conséquences spectaculaires :
• En 1991, naufrage de la plateforme pétrolière Sleipner A (210m de fond) à cause d’ap-
proximation dans des calculs lors de la conception (coût : 700 M$). 1
• En 1996, Ariane 5 est détruite peu après le décollage à cause d’une conversion double
(64 bits) → int (16 bits) qui conduit à interpréter comme négative une correction voulue
positive (coût : 500 M$). 2
Pour expliquer ce phénomène (voire comprendre comment le prévoir) nous allons examiner de
quelle manière le processeur calcule au niveau des circuits logiques. Nous verrons ainsi comment
les limitations techniques bas-niveau se répercutent à un plus haut niveau, par exemple dans les
langages de programmation C ou python.
1. https://en.wikipedia.org/wiki/Sleipner_A
2. https://en.wikipedia.org/wiki/Ariane_5
7
Explicitons cela.
et cette écriture est unique si l’on impose ak−1 6= 0 ou, pour x = 0, que k = 1. Le mot
ak−1 ak−2 . . . a0 sur l’alphabet {0, 1} est appelé la représentation binaire de x ; on écrit cela
x = (ak−1 ak−2 . . . a0 )2 . Chaque ai est un bit (contraction de binary digit).
D’autre part, le calcul Booléen définit un ensemble de règles de calcul sur des variables pou-
vant prendre deux valeurs, traditionnellement décrites comme vrai et faux. (De telles variables
sont appelées Booléennes.) Il définit des opérations sur ces variables, les plus classiques prenant
un argument (opérateur non, noté ¬) ou deux arguments (opérateurs et, noté ∧, et ou, noté ∨).
Ces règles de calcul sont données sous la forme de tables de vérités. Cf
https://en.wikipedia.org/wiki/Boolean_algebra#Basic_operations
L’ensemble des valeurs possibles pour un bit, {0, 1}, est de taille 2, tout comme l’est l’en-
semble des valeurs possibles pour une variable Booléenne, {vrai, faux}. On peut donc fixer
une bijection {0, 1} → {vrai, faux} et interpréter une formule de calcul Booléen comme s’ap-
pliquant à des bits. Il existe deux bijections possibles, et on peut en théorie utiliser n’importe
laquelle. Celle couramment utilisée identifie 0 à faux et 1 à vrai. Avec cette convention (qui
est la seule que l’on utilisera dans ce cours), cette interprétation donne que 0 ∨ 0 = 0 (puisque
faux ou faux = faux en calcul Booléen) et 0 ∨ 1 = 1 (puisque faux ou vrai = vrai en calcul
Booléen).
Nous allons appeler une « interprétation d’une fonction de l’algèbre Booléenne au travers de la
bijection {0, 1} → {vrai, faux} » une fonction Booléenne.
À ce stade, on a simplement défini des règles de calcul sur {0, 1} par interprétation (via
une bijection {0, 1} → {vrai, faux}) des règles de calcul Booléen. Il s’avère que les fonctions
Booléenes ainsi obtenues permettent de reproduire le calcul arithmétique. Supposons que l’on
additionne deux nombres 1-bit a0 et b0 . Le résultat est un nombre 1- ou 2-bits, donc écrivons-le
c1 c0 (quitte à ce que c1 vaille 0). On peut décrire la fonction (a0 , b0 ) 7→ c1 par sa table de valeurs :
a0 \ b0 0 1
0 0 0
1 0 1
Remarquons que cette table de valeurs coïncide en tous points avec l’interprétation de la table
de vérité de l’opération ∧ du calcul Booléen. Autrement dit, c1 coïncide avec a0 ∧ b0 .
8
La deuxième idée pour mécaniser le calcul est la suivante :
Cela peut par exemple se faire par un circuit électrique. On dispose autant de générateurs que
l’on a de variables. On allume un générateur si et seulement si la variable correspondante prends
la valeur vrai. On utilise ces générateurs pour commander des interrupteurs, que l’on dispose en
série pour réaliser un et, en parallèle pour réaliser un ou. Ce n’est bien sûr pas le seul système
physique (de la plomberie marcherait tout aussi bien).
Concevons un circuit réalisant l’addition de deux nombres 2-bits. Notons (c2 c1 c0 )2 l’écriture
binaire de la somme (a1 a0 )2 +(b1 b0 )2 et mettons en œuvre l’algorithme d’addition chiffre à chiffre
avec propagation des retenues appris en primaire. Rappelons qu’en calcul Booléen, l’opérateur
ou exclusif est défini par a ⊕ b = (a ∨ b) ∧ (¬(a ∧ b)).
def
r1
a0 + b0 : c0 =a0 ⊕ b0 et (retenue) = a0 ∧ b0
r1 r1
a1 + b1 + : c1 =a1 ⊕ b1 ⊕
qui produit la retenue : c2 =(a1 ∧ b1 ) ∨ ((a1 ⊕ b1 )∧r1 )
On utilise des portes logiques schématisées comme suit (dans l’ordre : et, ou, non, ou exclusif) :
a0 c0
b0 c1
a1
b1 c2
9
Exercice 3 FF Le circuit additionneur tel qu’on l’a dessiné comporte des croise-
ments, ce qui peut s’avérer génant pour certains modes de réalisation (par exemple
par circuit imprimé). On peut travailler à le dessiner sans croisement, mais que faire
si on souhaite réaliser un circuit qui n’admet pas de tracé sans croisement ? Voyons
cela...
a. Pour a, b ∈ {0, 1} on définit
def def def
c = a ⊕ b, d = a ⊕ c, e = b ⊕ c.
Prouver que d = b et e = a.
b. En déduire un circuit « échangeur ». Votre circuit doit être dessiné dans un
carré, les entrées étant aux deux coins gauches et les sorties aux deux coins
droites. Chaque sortie doit être égale à l’entrée du coin opposée. Votre circuit
doit être sans croisement.
Ensuite, un circuit opère en “une passe” sur un entier de taille fixée (par exemple 2 bits
dans notre circuit additionneur ci-dessus). Un processeur travaille donc généralement en une ou
plusieurs tailles prédéterminées : lorsque l’on dit d’un processeur qu’il est « 64 bits » c’est pour
signifier que son arithmétique (entre autres) travaille sur des entiers de taille 64 bits.
Enfin, le résultat d’une opération arithmétique a généralement la même taille que ses entrées.
Autrement dit, dans l’exemple ci-dessus le bit c2 serait “jeté”. Un processeur b-bits tronque les
bits de poids supérieur à b, et calcule donc modulo 2b . Comme nous le verrons, les bits tronqués
sont temporairement disponible pour qui souhaite les prendre en compte.
10
Exercice 4 F
a. Quelle est la taille, en bits, des types int, short et char en langage C sur votre
machine ? Indication : on pourra utiliser l’instruction sizeof.
b. Proposez et réalisez, pour un de ces types, une expérience testant que l’addition
est bien faite modulo 2T où T est sa taille.
Avant tout, puisqu’il existe 2b mots machines b-bits distincts, on ne peut coder que 2b nombres
distincts sur un mot machine. Pour les entiers naturels, ce sont bien évidemment les entiers 0,
1, . . . , 2b − 1 qui sont codés. Pour les entiers relatifs, il semble raisonnable de « centrer le 0 »,
et donc de consacrer la moitié des mots machines au codage d’entiers positifs et l’autre moitié
au codage d’entiers négatifs. Ainsi, les mots machine de taille b codent généralement les entiers
entre −2b−1 et 2b−1 (nous allons préciser sous peu si les bornes sont incluses).
La règle de codage d’un entier relatif x sur un mot machine b bits consiste à l’encoder par
le même mot machine que l’entier naturel y ∈ [0, 2b−1 ] qui satisfait x = y mod 2b . Ainsi, si
0 ≤ x ≤ 2b−1 alors l’encodage de x comme entier relatif coïncide avec son encodage comme
entier naturel. Les 0 > x > −2b−1 sont, quant à eux, encodés par le mot machine correspondant
à l’écriture binaire de x + 2b .
Fixons 0 ≤ x ≤ 2b−1 et notons m = ab−1 ab−2 . . . a0 le mot machine qui le code. Le nombre −x
est codé par le mot m0 qui représente l’écriture binaire de 2b − x. On peut facilement vérifier que
m0 peut être obtenue depuis m en (i) inversant chaque bit ai (on remplace 0 par 1 et 1 par 0), et
(ii) ajoutant 1 au résultat. L’opération consistant à inverser chacun des bits d’un mot machine
est appelée complément à deux. Pour cette raison, cette règle d’encodage des entiers naturels est
appelée règle du complément à deux.
Il est très pertinent de se demander pourquoi la règle du complément à deux a été préférée
à une alternative plus simple. Considérons par exemple la règle du bit de signe, qui consacre
un bit (par exemple ab−1 ) au codage du signe (par exemple 0 ↔ + et 1 ↔ −) et le reste, soit
ab−2 ab−3 . . . a0 , au codage de l’entier naturel |x| (par sa simple écriture binaire sur b − 1 bits).
Voici une raison pour laquelle préférer la règle du complément à deux à la règle du bit de signe :
Expliquons cela. Pour faire additionner deux entiers (naturels ou relatifs) à un circuit, on procède
en trois étapes : (i) on code ces entiers sur des mots machines, (ii) on fait traiter ces mots machines
par un circuit (additionneur), et (iii) on décode le mot-machine retourné comme résultat par le
11
circuit en un entier relatif. Naturellement, le circuit qu’il convient d’utiliser à l’étape (ii) dépends
du codage choisi. L’observation ci-dessus exprime le fait que ce circuit est le même pour le codage
des entiers naturels par leur écriture binaire, et par le codage des entiers relatifs par complément
à deux. (Je vous laisse le soin de vérifier ce point : c’est une conséquence du fait que le circuit
additionneur que l’on a décrit calcule modulo 2b .)
Exercice 6 FF
a. Donnez les mots machines m1 et m−1 qui codent les entiers relatifs 1 et −1 par
la règle du bit de signe sur 4 bits.
b. Donnez le mot machine m0 produit par un circuit additionneur 4-bits « pour
nombres naturels » lorsqu’il a en entrée m1 et m−1 .
c. Quel est l’entier relatif codé par m0 en règle du bit de signe sur 4 bits ?
Nous verrons que le codage par bit de signe est utilisé pour les nombres à virgule, pour lesquels
il faut de toute façon refaire les circuits ! Concluons en observant que la règle du complément à
deux induit le fait que le bit le plus à gauche (dit « de poids fort ») révèle le signe du nombre
codé.
Précisons ce point. En C, déclarer une variable a deux effets. D’une part cela réserve une
ou plusieurs cases mémoire (le nombre dépends du type de la variable). D’autre part, cela crée
une manière d’accéder à ces cases mémoires avec une interprétation prescrite par le type de
la variable déclarée. Ainsi, on peut accéder à une même case mémoire via des interprétations
diverses.
12
Chaque type d’entier (signé ou non-signé) a un intervalle de représentation. Pour un entier
non-signé b-bit cet intervalle est [0, 2b − 1], pour un entier signé b-bits c’est [−2b−1 , 2b−1 −
1]. Lorsqu’une opération arithmétique sur des entiers typés produit un résultat qui sort de
l’intervalle de représentation de ce type, on parle de dépassement de capacité.
float a = 0.1;
crée une variable de type flottant et lui affecte la valeur représentable la plus proche de 0.1 (c’est
à dire 0.1 si ce nombre est représentable et une approximation sinon).
k−1
X
(ak−1 ak−2 . . . a0 .a−1 a−2 . . . a−` )2 code le nombre ai 2i .
i=−`
On peut donc décider de représenter des nombres à virgule en consacrant par exemple k bits à
la partie entière et ` bits à la partie fractionnaire ; on parlera ici de format k.` bits.
Exercice 9 FF
a. Prouver qu’aucun entier puissance de 2 n’est divisible par 10.
b. En déduire que le nombre décimal 0.1 n’est pas représentable sur k.` bits,
quelque soient k et `.
13
Remarquons que l’on a là une alternative intéressante à l’algorithme de division par puis-
sances décroissantes pour calculer la représentation tronquée d’un nombre x quelconque sur k.`
bits : calculer x0 = b2` xc, convertir x0 en binaire sur k + ` bits, puis décaler la virgule de ` posi-
tions vers la gauche. Cela ne fonctionne que si l’on connaît à l’avance le nombre ` de bits dévolus
à la partie fractionnaire. Avec cette restriction, cette méthode présente l’avantage sur la division
par puissances décroissantes que la partie “résiduelle” est éliminée en début de conversion.
La notation scientifique permet d’écrire de manière compacte certains grands nombres, mais
pas tous. Prenons deux exemples d’écriture scientifique à base 2 de nombres 16 bits :
Dans les cas favorables, on peut représenter un nombre x en utilisant de l’ordre de log log x bits
(contre log x bits en codage binaire standard).
Fixons, à nouveau, des nombres k de bits dévolus au codage de l’exposant et ` de bits dévolus
à la mantisse ; on parle ici de codage sur k ⊕ ` bits. Supposons qu’un nombre x est approximé
dans un codage par le nombre y le plus proche de x qui soit représentable. Une première mesure
de l’approximation est l’écart absolu |x − y|. L’écart absolu est au plus 2−` en virgule fixe k.` bits
k
et est au plus 2−` ∗ 22 en virgule flottante k ⊕ ` bits (l’écart absolu de 2−` entre les mantisses
est amplifié par le facteur lié à l’exposant). L’écart absolu rends cependant mal compte de
l’ampleur de l’approximation : il est le même entre 2 et 1 qu’entre 10 001 et 10 000. Une mesure
|x−y|
plus pertinente est l’écart relatif max(x,y) . L’écart relatif est au plus 2−` que ce soit en virgule
fixe k.` bits ou en virgule flottante k ⊕ ` bits.
14
1.4.1 Des formats
La norme IEEE 754-2019 définit plusieurs formats standard d’encodage de nombres à virgules
flottantes. Ces formats contiennent des règles d’encodage détaillées et soigneusement optimisées
(comme nous le verrons). La norme définit non seulement le format des nombres flottants, mais
aussi les règles d’arrondi (round to nearest, ties to even), la représentation du zéro signé et des
infinis, la gestion des exceptions (division par zéro, overflow...),
Les formats s’appuient sur une notation scientifique, certains formats étant à base binaire
et d’autre à base décimale. On ne s’intéresse ici qu’aux formats à base binaire, les plus cou-
rants. La base décimale permet de rendre représentable tous les nombres décimaux de petite
taille (quelques chiffres après la virgule, par exemple 0.1). Cela s’avère important pour certains
domaine d’application tels que la finance ou la comptabilité, où l’on ne peut pas se permettre
d’effectuer des arrondis en série.
Chaque format défini par la norme IEEE 754 suit la même règle : le mot binaire codant le
flottant est divisé en trois zones stockant respectivement l’information de signe S, d’exposant
E et de mantisse M . Ces informations apparaissent dans cet ordre du bit le plus signifiant au
moins signifiant :
S E M
(signe) (exposant biaisé) (mantisse)
Quatre tailles de formats à base binaire sont définies, utilisant respectivement 16, 32, 64 et 128
bits pour représenter un nombre (on parle de simple / double / quadruple / octuple précision).
La répartition des bits entre signe, exposant et mantisse est la suivante :
Ainsi, le flottant codé par (1101 1000 0010 0000)2 a S = 1, E = 1 0110 et M = 000 0010 0000.
avec
Ce choix de biais assure que lorsque l’exposant est codé sur k bits, sa valeur est dans ] − 2k−1 +
1, 2k−1 −1] ; les valeurs −2k−1 +1 et 2k−1 peuvent être atteintes par E −biais mais correspondent
à des règles spéciales de décodage (cf ci-dessous). Soulignons que le codage des exposants négatifs
15
ne se fait pas ici par complément à deux mais par un simple décalage. Lorsque E est codé sur
k bits la valeur de biais est 2k−1 − 1.
Pour tous les nombres sauf 0, le premier bit significatif de la mantisse est 1. La norme choisit
de rendre ce 1 implicite afin de gagner 1 bit d’encodage. Cela a pour conséquence que le 0 doit
être codé par une règle spéciale. Les règles particulières de décodage sont les suivantes :
• E = (00 . . . 0)2 et M = 0 → code +0 ou −0 selon S.
• E = (11 . . . 1)2 et M = 0 → code +∞ ou −∞ selon S.
• E = (00 . . . 0)2 et M 6= 0 → comme la règle générale mais remplacer 1.M par 0.M .
• E = (11 . . . 1)2 et M 6= 0 → code NaN (not a number ).
Cela va sans dire que le calcul sur flottants demande la conception de circuits additionneurs
(et multiplicateurs, diviseurs, etc.) spécifiques.
1.4.3 Exercices
et affiche la valeur (true ou false) du test “a+b == c”. Reportez vos observa-
tions ci-dessous
• (1,4) : • (3,4) :
• (1,5) : • (3,5) :
• (1,6) : • (3,6) :
• (1,7) : • (3,7) :
• (1,8) : • (3,8) :
k 1
X 1 X 1
Exercice 12 F Calculer les sommes et pour k valant 103 , 106 , 109 , . . . et
i i
i=1 i=k
comparer les résultats.
16
Exercice 13 FF Cet exercice consiste à utiliser l’extrait de la documentation
754-2008 - IEEE Standard for Floating-Point Arithmetic, disponible sur le Arche,
pour décoder et encoder des nombres dans le format binaire double précision (qui
correspond au type float en C).
a. Quelle est la taille mémoire d’un float, et quelles tailles font sa mantisse et
son exposant ?
b. Donner en notation scientifique décimale la valeur du float codé par 0x 414B D000.
c. Donner la mantisse et l’exposant de l’encodage en float du nombre 0.1.
17
18
Chapitre 2
D’une part, nous clarifions de quelle manière un programmeur C interagit avec la mémoire.
L’objectif de ce premier modèle est de comprendre ce que fait un ensemble d’instructions qui
mettent en jeu la mémoire. En revanche, comme nous le verrons, ce modèle ne rend aucunement
compte des performances d’un tel ensemble d’instructions ; ce sera le rôle des modèles introduits
au chapitre suivant.
D’autre part, nous donnons une méthodologie sommaire de mesure des performances d’un
programme. Il ne s’agit pas d’apprendre à profiler un projet logiciel mais d’examiner les perfor-
mances ou contre-performances de quelques lignes de code C. Cela nous permettra, au fil des
séances suivantes, de chronométrer de petits programmes pour mettre en évidence des accéléra-
tions ou des ralentissements liés à l’architecture matérielle.
En C, un pointeur est une variable interprétée comme une adresse. Ainsi, déclarer int* p
crée une variable p dont la valeur est interprétée comme l’adresse d’une variable de type int.
Il est conseillé pour ce cours d’avoir les idées claires sur l’arithmétique des poin-
teurs. Par exemple, comment la valeur de p change-t-elle lorsque l’on fait p++ ?
19
système) entre tous les processus exécutés par la machine. Ce partage se fait par un mécanisme
de traduction des adresses mémoire que manipule le processus (dites adresses virtuelles
de ce processus) en adresses mémoire que manipule le composant électronique (dites
adresses physiques). Ainsi, deux processus peuvent avoir l’impression d’avoir chacun rangé une
information à l’adresse 0x42 000 sans que ces informations ne se télescopent : cette adresse
virtuelle sera traduite en deux adresses physiques distinctes.
Sur la pile (« stack »). On peut réserver de l’espace mémoire par déclaration de variable, et
en particulier de tableaux. Par exemple :
int tab[100];
20
Sur le tas (« heap »). On peut réserver de l’espace mémoire via le mécanisme d’allocation
dynamique du C au moyen de la fonction malloc de la bibliothèque <stdlib.h. Le code qui
réalise la même tâche que la commande ci-dessus est :
#include <stdlib.h>
int* tab;
t = malloc(100*sizeof(int));
L’espace mémoire réservé n’est pas non plus initialisé. Soulignons ici aussi quelques particularités
de cette méthode de réservation mémoire :
• La taille de l’espace mémoire alloué (ici 100*sizeof(int)) n’a besoin d’être connu qu’au
moment de l’exécution de cette commande. Autrement dit, ce peut être une variable dont
la valeur résulte d’un calcul fait dans le programme ou d’un paramètre passé en ligne de
commande.
• L’espace mémoire ainsi alloué n’est libéré qu’au moyen de la commande free (ou à la
terminaison du processus).
• L’espace mémoire est réservé dans une partie de la mémoire allouée au processus que l’on
appelle son tas. Le tas est généralement de taille comparable à la mémoire disponible sur
la machine.
Exercice 2 F Vérifiez que vous arrivez à réserver sur le tas un tableau plus grand
que ce que vous pouvez réserver sur la pile.
2.1.3 Endianness
Une donnée qui fait plus d’un octet doit occuper plusieurs cases mémoires. Supposons que
sur notre machine un int occupe 32 bits (on peut le vérifier par exemple avec sizeof()). À
l’issue de la déclaration
int a=0xFFCC8822;
on a écrit les 4 octets 0xFF, 0xCC, 0x88 et 0x22, qui composent notre entier, en mémoire. Cela
peut se faire de deux manières :
Une manière simple de déterminer l’endianness courante consiste à vérifier quel est le premier
octet :
21
int a=0xFFCC8822;
char* p = (char*)&a;
2.2 Chronométrage
Passons maintenant au second sujet de ce chapitre.
2.2.1 Problématique
Lorsque l’on s’intéresse au temps d’exécution d’un programme, il convient de distinguer le
temps écoulé du temps de processeur consommé. Le premier correspond au temps mesuré, par
exemple avec notre montre, entre le début et la fin du programme. Le second correspond au temps
de processeur consacré à son exécution. Sur une machine comportant un seul processeur qui
exécute une seul tâche, comme par exemple une calculatrice programmable, on peut s’attendre
à ces deux notions soient proches. Sur une machine multi-processeur au système multi-tâches,
ces deux notions peuvent être très différentes.
Notre but va être de mesurer le temps de processeur consommé par l’exécution d’un morceau
de programme afin de tester une hypothèse ou de comparer les performances de deux codes a
priori similaire. Par exemple, on peut vouloir tester l’hypothèse que le temps de processeur pris
par l’exécution du programme
est proportionnel à la valeur de MAX. On peut aussi vouloir comparer les temps pris pour
parcourir un tableau n × n ligne par ligne et colonne par colonne :
22
__rdtscp() s’appelle avec en argument un pointeur sur un unsigned int. La fonction
renvoie la valeur du tsc au format unsigned long int et écrit dans l’entier passé en
argument l’identifiant du cœur qui exécute le programme.
On va chronométrer un code en mesurant la différence entre les valeurs du tsc avant et après
son exécution. Par exemple, la fonction test suivante retourne le nombre de cycles d’horloge
écoulés entre le début et la fin de la boucle for.
#include <x86intrin.h>
tic = __rdtscp(&ui);
for (int i=0; i < MAX; ++i)
a = a*a;
toc = __rdtscp(&ui);
return toc-tic;
}
Deux précisions :
a. On ne fera généralement rien du résultat écrit dans la variable ui. Il faut néanmoins la
fournir à la fonction __rdtscp().
b. Le résultat de __rdtscp() est dans l’unité “nombre de cycles d’horloges”. Il peut être
tentant de vouloir convertir cela en quelque chose de plus courant, par exemple des mil-
lisecondes. C’est généralement inutile pour ce que l’on souhaite faire. Par exemple, pour
tester l’hypothèse que le temps pris par la boucle ci-dessus est proportionnel à la valeur
de MAX, l’unité “nombre de cycles d’horloge” convient tout à fait.
23
2.2.4 Compléments
Signalons que d’autres outils de chronométrage sont disponibles.
• Sous linux, une première option est la commande time. Exécuter time prog lance l’exé-
cution de prog et affiche en fin d’exécution le temps écoulé et le temps CPU. C’est simple
mais limité : on ne peut mesurer qu’un programme complet.
• La bibliothèque C time.h fournit diverses fonctions qui relèvent “l’horloge courante”. Elles
fonctionnent sur le même modèle que __rdtscp() mais sont bien moins précises.
• Sous linux, l’outil perf opère de la même manière que la commande time. Il a l’inconvé-
nient de mesurer l’ensemble du programme, mais fournit un diagnostic assez complet, avec
notamment la lecture de plusieurs compteurs du processeur.
Les plus motivé·es pourront affiner leurs chronométrages en apprenant à se servir des com-
mandes linux telles que cpupower, taskset, setarch, . . . Le white paper écrit par Gabriele
Paoloni (ingénieur Intel) How to Benchmark Code Execution Times on Intel ® IA-32 and IA-64
Instruction Set Architectures, en accès libre, explique comment diminuer des erreurs de mesures
liées au fonctionnement du processeur. Nous en recommandons la lecture attentive après les
séances 4 (assembleur) et 5 (pipeline).
2.3 Exercices
Exercice 4 FF Écrivez une fonction qui calcule la somme des carrés des entiers
de 1 jusqu’à n, où n est un paramètre. Chronométrez cette fonction pour différentes
valeurs de n :
n
1000 105 107 108 109
appel
1er
2ème
3ème
écart max.
moyenne en %
24
Exercice 6 FF On va maintenant écrire une fonction print_timing qui prends en
argument une autre fonction et qui mesure son temps d’exécution. L’idée est d’écrire
une fois pour toutes un code de chronométrage soigneux, qui prends en compte les
phénomènes de mise en route et qui moyenne sur plusieurs exécutions. Supposons
que notre fonction à tester soit
7 }
1 print_timing(12,test);
n
1000 105 107 108 109
appel
mesure
print_timing
25
À partir de maintenant, et pour toutes les séances à venir, tous les
chronométrages doivent être faits par print_timing.
Les deux derniers exercices ont pour objectifs de pratiquer le chronométrage de code et de
mettre en évidence des phénomènes que l’on expliquera à la séance prochaine. Pour les exercices
qui suivent, déclarez un tableau TAB de TAILLE entiers int. Lorsque la valeur de TAILLE n’est
pas précisée, on la prendra ≈ 109 .
pas 1 2 3 4 8 16 32
mesure
Qu’observe-t-on ?
Faites des mesures plus systématiques et tracez la courbe du temps pris en fonction
du pas, pour 1 ≤ pas ≤ 1000.
Qu’observe-t-on ?
26
Chapitre 3
Hiérarchie mémoire
Soulignons qu’il ne s’agit pas ici de remettre en cause la description donnée par le « modèle
plat » de la manière dont la mémoire se présente au programmeur (un grand tableau d’octet, où
l’on peut accéder à un octet simplement à partir de son adresse). Il s’agit d’examiner comment
cette “interface” est implémentée matériellement et l’impact de cette implémentation sur les
performances de différents types d’accès mémoire.
3.1 Problématique
Il existe de nombreux supports mémoire, dont la fonction est de stocker puis restituer de
l’information : RAM, disque dur (HDD, SSD), USB, CD ou DVD, mémoire cache, mais aussi
bande magnétique, carte perforée, papier, . . . Le tableau suivant donne les ordres de grandeurs
de trois caractéristiques importantes pour quelques types de mémoire :
Les hiérarchies mémoire sont des systèmes combinant différents types de mémoire afin de
garantir à la fois une grande capacité mémoire et un temps d’accès rapide.
27
3.2 Principe d’un cache
Une mémoire cache, ou antémémoire, est un système qui accélère les accès d’un utilisateur
à un stockage de données selon le principe suivant :
• Le cache est constitué d’une mémoire plus rapide mais plus petite que le stockage, et il
garde une copie d’une petite quantité des données du stockage.
• L’utilisateur adresse ses requêtes (en lecture ou en écriture) au cache et non pas au stockage.
• Si le cache dispose d’une copie des données concernées par la requête de l’utilisateur, il
répond directement ; l’accès à la donnée par l’utilisateur est alors dite en cache (cache-hit).
• Sinon, le cache adresse une requête au stockage pour la donnée demandée par l’utilisateur.
Une fois qu’il l’a reçue, il répond à la requête de l’utilisateur ; l’accès est dit hors-cache
(cache-miss). Une fois cela fait, le cache garde une copie de la donnée. Si la mémoire du
cache est pleine, le cache fait de la place en se débarassant d’une donnée plus ancienne.
On trouve ce principe dans les navigateurs internet ou dans les bases de données distribuées.
Soulignons quelques points :
La division par bloc est fixe. Ainsi, les octets d’adresses 0 à b − 1 forment un premier bloc,
ceux d’adresses de b à 2b − 1 forment un second bloc, etc. Lorsque la mémoire rapide a besoin
d’un octet, il récupère de la mémoire lente la totalité du bloc contenant cet octet. Ainsi, lors d’un
accès hors-cache à un octet en début de bloc, les octets qui le suivent l’accompagnent en cache ;
lorsque l’accès hors-cache porte sur un octet en fin de bloc, ce sont les octets qui le précèdent
qui l’accompagnent en cache.
28
Le transfert des données par blocs permet souvent d’amortir le coût de latence d’un accès en
mémoire lente sur plusieurs accès en mémoire rapide : en effet, des octets consécutifs en mémoire
lente sont souvent utilisés successivement.
Puisque les blocs sont formés d’octets consécutifs, et que le premier bloc commence à
l’adresse 0, le numéro du bloc qui contient l’octet d’adresse adr est adr/b (ici / est la divi-
sion entière). En pratique, b est une puissance de 2 (notons le b = 2k ) et le numéro de bloc d’un
octet s’obtient donc en oubliant les k derniers bits de l’écriture binaire de son adresse. Si k est
un multiple de 4, cela revient aussi à oublier les k/4 derniers chiffres hexadécimaux. Lorsque
l’on manipule des adresses en hexadécimal, la division est facile si k est un multiple de 8 (par
exemple b = 256) mais un peu pénible sinon. On peut, à la place de diviser, simplement mettre
à 0 les k derniers bits de l’adresse. Cette opération transforme l’adresse considérée en l’adresse
du premier octet de son bloc, information qui caractérise le bloc en question.
Considérons par exemple deux variables de type char qui ont pour adresses respectives
0xF03E AAE4 et 0xF03E AABE. Si la taille de bloc est 256, ces variables sont toutes les deux dans
le bloc 0xF03E AA. Si la taille de bloc est 64, les premiers octets de leurs blocs respectifs sont
aux adresses 0xF03E AAC0 (car mettre à 0 les 6 bits de poids faible de E4, 1110 0100 en binaire,
donne C0) et 0xF03E AA80 (car BE s’écrit 1011 1110 en binaire, ce qui devient 80 après mise à
0 des 6 bits de poids faible). Ils sont donc dans des blocs différents.
29
3.3.3 Pagination complètement associative
La mémoire rapide ne peut stocker qu’une petite partie des blocs que contient la mémoire
lente. Aussi, assez vite, le chargement d’un nouveau bloc doit provoquer le déchargement d’un
autre bloc. La manière de choisir quel bloc sera écrasé est spécifiée par la stratégie de pagination.
Une stratégie de pagination est dite complètement associative si tout bloc de la mémoire
lente peut être chargé dans tout emplacement de la mémoire rapide. C’est la situation que l’on
examine ici ; on discutera des stratégies à associativité partielle en Section 3.5
3.5.
La stratégie de pagination que l’on considère consiste à décharger le bloc pour lequel le
dernier accès est le moins récent parmi les blocs actuellement en cache. Cette stratégie est
désignée par l’acronyme LRU, pour least recently used. Il s’agit d’une heuristique naturelle si
l’on fait l’hypothèse que les accès passés sont une bonne indication des accès futurs. Il s’agit
aussi d’une solution qui offre des garanties théoriques intéressantes mais que l’on ne détaillera
pas ici. 1
Exercice 2 FF On construit une liste chaînée dont les éléments sont définis
comme suit :
1 struct noeud
2 {
3 int valeur;
4 struct noeud* prec;
5 struct noeud* suiv;
6 };
On travaille sur une machine sur laquelle un int occupe 4 octets et un pointeur oc-
cupe 8 octets. Une structure noeud occupe donc 20 octets. 2 Notre liste est construite
par insertions successive, l’espace mémoire de chaque nœud étant réservé à sa créa-
tion. Les nœuds ne sont pas inséré dans leur ordre final, si bien que les adresses de
début de chaque nœud sont les suivantes (p est la position du nœud dans la list,
adresse est l’adresse du premier octet de ce nœud) :
1. En deux mots... On peut prouver (par une analyse amortie de la stratégie move-to-front dans les listes
auto-organisatrices) que pour toute séquence d’accès, LRU avec m blocs produit au plus 2 fois plus d’accès hors-
cache que n’en produirait la meilleure stratégie pour cette séquence avec m/2 blocs. C’est assez impressionnant
quand on y pense.
2. En réalité, une structure nœud occupe 24 octets... Question subsidiaire : vérifier cela expérimentalement et
l’expliquer !
30
a. Supposons que l’on travaille sur un système à 2 niveaux de mémoire pour lequel
la mémoire rapide a les paramètres suivants :
Le programme, et donc le programmeur, ne connaît une donnée mémorisée que par son
adresse en RAM. Losqu’il souhaite y accéder, en lecture ou en écriture, il adresse une demande
au cache L1 (le plus rapide et le plus petit). Si le cache L1 a cette donnée en mémoire, il répond
directement (accès en cache L1), sinon, il la demande au cache L2 puis répond (accès hors-cache
L1). Quand le cache L1 adresse une demande au cache L2 (un peu moins rapide mais un peu
plus gros), celui ci répond directement s’il a la donnée en mémoire (accès en cache L2), sinon il la
demande au cache L3 et répond (accès hors-cache L2). Idem pour L3 : il répond aux demandes
adressées par L2 directement (accès en cache L3) ou après demande à la RAM (accès hors cache
L3). Ainsi, l’accès par le programme à un octet de donné peut être :
• un accès en cache L1, ou
• un accès hors cache L1 et en cache L2, ou
• un accès hors caches L1 et L2 et en cache L3, ou
• un accès hors caches L1, L2 et L3.
Ces situations occasionnent naturellement des temps d’attente de plus en plus long.
31
Figure 3.1 – Diagramme d’un cœur dans l’architecture Intel Skylake. Source :
https://en.wikichip.org/wiki/WikiChip.
https://en.wikichip.org/wiki/WikiChip
/sys/devices/system/cpu/cpu0/cache
L1 instruction L1 données L2 L3
Taille mémoire
Ligne de cache
32
et l’élément à la ième ligne et jème colonne est tab[i*N+j]. Il s’agit par exemple de
comparer les temps pris par les codes suivants :
parcours
en ligne
parcours
en colonne
3.5 Associativité
Le modèle de cache complètement associatif n’est qu’approximatif. Cette approximation
permet déjà de comprendre certains phénomènes, mais n’en explique pas d’autres. Par exemple,
l’exercice 7 ci-dessous va vous faire constater que lorsque l’on effectue des recherche dichotomique
sur des tableaux de taille
le cas N2 est substantiellement plus lent. Pour expliquer ce phénomène, il convient de raffiner
notre modèle de mémoire cache.
Un cache est généralement divisé en sous-cache indépendants. Le nombre de blocs que peut
stocker un de ces sous-caches est appelé l’associativité du cache. Ainsi, un cache de taille totale C,
C
de taille de bloc b et d’associativité a comporte s = ab sous-caches. Numérotons les sous-caches
de 0 à s − 1. Dans le cas d’associativité complète, il y a un unique sous-cache.
33
Chaque bloc de mémoire centrale est pré-affecté à un sous-cache. Ainsi, le bloc 0 est affecté
au sous-cache 0, le bloc 1 au sous-cache 1, . . . , le bloc s − 1 au sous-cache s − 1, le bloc s au
sous-cache 0, le bloc s + 1 au sous-cache 1, . . . Lorsque le cache accède à un nouveau bloc B,
ce dernier est transmis à son sous-cache d’affectation, disons S. La gestion de page est faite au
niveau du sous-cache S : ainsi, sous l’hypothèse d’une gestion de page LRU, le bloc qui sera
déchargé pour faire de la place à B est le bloc accédé le moins récemment parmi les blocs stockés
dans S. Si un programme n’accède qu’à des données se trouvant dans des blocs affectés à un
même sous-cache, seul ce sous-cache travaille.
Il est utile de pouvoir déterminer si deux adresses mémoire données, disons &a et &b, sont
associées au même sous-cache. On commence par calculer leurs numéros de blocs, puis on prend
ce numéro modulo le nombre de sous-caches. Autrement dit, on oublie les k bits de poids faible
(où 2k est la taille de bloc), puis on oublie les bits de poids fort pour ne garder que ` bits (où 2`
est le nombre de sous-caches).
• Réservez un tableau tab de N int, initialisez le par des entiers aléatoires entre
1 et T , puis triez-le.
• Réservez un second tableau req de R int et initialisez le par des entiers aléa-
toires entre 1 et T .
• Programmez ou importez une fonction qui réalise une recherche dichotomique
d’un int x donné en argument dans le tableau trié tab.
• Chronométrez le temps que cela prend d’appeler votre fonction de recherche
dichotomique pour chacune des R valeurs du tableau req.
Que constatez-vous ?
34
3.6 Exercice optionnel : transposition rapide de matrice
Le modèle complètement associatif de hiérarchie mémoire suffit déjà à guider la concep-
tion d’algorithmes bien plus efficace pour l’architecture considérée. L’exercice qui suit propose
d’illustrer cela sur l’exemple de la transposition de matrice. Remarquons que d’un point de vue
algorithmique, il n’y a rien de bien intéressant à dire : il faut accéder à chaque case mémoire au
moins une fois, et une fois suffit. Il s’avère que l’ordre dans lequel on accède à ces cases mémoire
a des conséquences importantes sur les performances... et qu’il est facile de décrire récursivement
un bon ordre.
Dans cet exercice, une matrice n × m est interprétée comme un tableau 2D à n lignes
et m colonnes et est stockée en mémoire dans un tableau (1D) de n*m int, avec la
convention que l’élément en ième ligne et jème colonne est stocké à l’indice i ∗ n + j
(comme à l’exercice 4). On décrira systématiquement une matrice par la donnée de
ses dimensions (int n et int m) et du tableau 1D (int *).
On rappelle que transposer une matrice (n,m,mat) revient à échanger les entrées
mat(i,j) et mat(j,i) pour toutes les paires d’indices i, j où i < j.
a. Écrire une fonction qui initialise une matrice par des entiers aléatoires entre 0
et 99.
b. Écrire une fonction qui transpose une matrice n∗n par une simple double-boucle
sur les paires (i, j) avec 1 ≤ i < j ≤ n. Mesurez ses temps d’exécution pour
différentes tailles de matrice :
temps
A B
c. Soit M = une matrice m × n, où A, B, C et D sont des matrices
C D
t
A Ct
(ce sont des sous-matrices de M ). On rappelle que M = t , ce qui
B t Dt
permet de réduire la transposition d’une grande matrice à celle de petites ma-
trices. Écrire une nouvelle fonction de transposition qui se base sur cette idée
et mesurez ses temps d’exécution pour différentes tailles de matrice.
temps
35
d. Étendez cette idée récursivement. Mesurez les temps d’exécution de votre fonc-
tion pour différentes tailles de matrice.
temps
36
Chapitre 4
Lors de la 4ème séance de ce cours, nous allons examiner le code produit par gcc et d’analyser
certaines des optimisations qu’il effectue. Ce code produit est de l’assembleur (x86 dans notre
cas), aussi il nous faut nous familiariser avec ce langage, suffisamment pour pouvoir lire un
code assembleur. Ce chapitre propose une introduction rapide à l’assembleur et est à considérer
comme un « kit de survie » pour la 4ème séance.
4.1 Généralités
Assembleur. Chaque processeur dispose d’un langage natif, appelé assembleur de ce proces-
seur. Il y a donc a priori autant de langages assembleurs qu’il y a de modèles de processeurs. En
pratique, il existe des familles de processeurs de langages compatibles à rebours ; ainsi, chaque
processeur intel de la famille x86 peut exécuter tout code écrit pour ses prédécesseurs (mais pas
l’inverse, car de nouvelles instructions ont pu apparaître).
1 push rbp
2 mov rbp,rsp
1 int main(){
3 mov DWORD PTR [rbp-0xc],0x1
2 int a,b,c;
4 mov DWORD PTR [rbp-0x8],0x3
3
5 mov DWORD PTR [rbp-0x4],0xc
4 a=1;
6 mov eax,DWORD PTR [rbp-0xc]
5 b=3;
7 sub eax,DWORD PTR [rbp-0x8]
6 c=12;
8 mov edx,eax
7
9 mov eax,DWORD PTR [rbp-0x4]
8 a = a-b+c;
10 add eax,edx
9
11 mov DWORD PTR [rbp-0xc],eax
10 return 0;
12 mov eax,0x0
11 }
13 pop rbp
14 ret
37
Langage machine. Les instructions assembleur sont relativement expressives. Le langage
machine est une convention d’encodage des instructions assembleur en nombres. Ce sont ces
nombres qui sont stockés en mémoire. Lorsque le CPU exécute un programme, il charge ces
nombres depuis la mémoire les uns après les autres, les décode, et agit en conséquence.
55 48 89 e5 c7 45 f4 01 00 00 00 c7 45 f8 03
00 00 00 c7 45 fc 0c 00 00 00 8b 45 f4 2b 45
f8 89 c2 8b 45 fc 01 d0 89 45 f4 b8 00 00 00
00 5d c3
Convention Intel VS AT&T. L’assembleur permet d’écrire du code facilement lisible par
un humain et directement traduisible en langage machine : chaque instruction assembleur cor-
respond de manière non-ambigüe à un code du langage machine. Au moins deux conventions de
syntaxe assembleur x86 sont couramment utilisées aujourd’hui : la syntaxe Intel et la syntaxe
AT&T.
Ces syntaxes diffèrent par exemple dans l’ordre des arguments. Ainsi, en syntaxe Intel, mov
rax,rbx décrit l’opération de copie du registre rbx dans le registre rax. En syntaxe AT&T,
cette même instruction décrit l’opération de copie du registre rax dans le registre rbx. Selon la
syntaxe choisie, l’instruction mov rax,rbx correspond à différents codes en langage machine.
Par gcc. On peut demander à gcc de produire un fichier texte contenant le code assembleur
qu’il produit. Cela se fait avec l’option -S. Par exemple
produit un fichier test.s contenant le code assembleur produit par gcc. L’option -masm=intel
indique que l’on souhaite utiliser la convention Intel.
Pour examiner un programme créé en compilant du code C par gcc, il est conseillé d’appliquer
objdump (avec les mêmes options que ci-dessus) au fichier .o plutôt qu’à l’exécutable. (Rappel :
pour faire créer à gcc un fichier objet (.o), il faut compiler avec l’option -c.)
38
Exercice 1 F Récupérez le code assembleur du programme suivant par chacune
des méthodes décrites ci-dessus.
9 int main() {
10 int count = 100000000;
11 sum(count);
12 }
Mnémonique. Chaque instruction assembleur est caractérisée par une mnémonique, ou mot-
clef, en lien avec la fonction de l’instruction. Voici quelques exemples (on donnera un peu plus
loin une liste plus fournie) :
mov copie une donnée
add effectue une addition
sub effectue une soustraction
inc incrémente
39
En convention Intel l’argument destination précède généralement l’argument source. Par ailleurs,
la valeur initiale de l’argument destination est parfois utilisée par l’instruction (par exemple pour
add, sub ou inc).
Arguments immédiats. On peut donner une valeur constante (par exemple 42) comme ar-
gument source mais pas comme argument destination. Les nombres sont interprétés comme des
décimaux s’ils ne sont pas préfixés, et comme des hexadécimaux s’ils sont préfixés par 0x.
Arguments indirects. On peut donner comme argument “le contenu de l’adresse mémoire
adr” au moyen de [adr]. Autrement dit, un argument entre crochets renvoie au contenu de
l’adresse mémoire en question. Par exemple
Ainsi, un registre entre crochets [ ] est considéré comme un pointeur. On peut mettre entre
crochets une adresse immédiate, c’est-à-dire constante. Par exemple :
mov rax, [0x4300] copie la valeur en mémoire à l’adresse 0x4300 dans rax
L’adresse mémoire indiquée entre [ ] peut inclure certains calculs, par exemple :
mov rax, [rbx-8] copie dans rax la valeur en mémoire à l’adresse rbx-8
mov rax, [rbx+rcx] copie dans rax la valeur en mémoire à l’adresse rbx+rcx
Le résultat du calcul fait entre [ ] n’est pas gardé. Ainsi, dans les deux exemples ci-dessus, les
registres rbx et rcx restent inchangés.
Comme les registres ne sont pas typés, le sens de [rax] peut être ambigü : s’agit-il de
l’octet, du mot 16 bits, du mot 32 bits ou du mot 64 bits contenu à l’adresse rax ? Dans les deux
exemples ci-dessus, on peut répondre à cette question par inférence : puisque l’autre opérande
est un registre 64 bits, le contenu mémoire est considéré comme une valeur 64 bits. En revanche,
l’instruction mov [rax],[rbx] pose problème : combien d’octets souhaite-t-on copier de l’adresse
rbx vers l’adresse rax ? On peut toujours (et on doit parfois) expliciter la taille souhaitée au
moyen de directives de taille BYTE PTR, WORD PTR, DWORD PTR, QWORD PTR, qui correspondent
respectivement à un pointeur sur un octet (byte=octet), sur un mot 16 bits (word ), sur un mot
32 bits (double word ) et sur un mot 64 bits (quadruple word ).
Sauvegarde de registres sur la pile. Le processeur dispose d’une pile. Elle fonctionne en
LIFO (last in, first out). On peut y ajouter le contenu d’un registre avec l’instruction push. On
peut en retirer la dernière valeur ajoutée avec l’instruction pop. En pratique, la pile est une zone
de la mémoire centrale pointée par le registre rsp.
40
4.3.2 Gestion de flot
Un apport important d’un langage comme le C est la capacité qu’il offre de contrôler le flot
du calcul au moyen d’instructions telles que if, for, while. . . Ces instructions n’ont pas
d’équivalent en assembleur, et sont émulés par les moyens suivants.
Adresses d’une instruction. Pour qu’un code assembleur puisse être exécuté, il est d’abord
chargé en mémoire sous la forme d’un code en langage machine. Chaque instruction est traduite
par un ou plusieurs octets ; l’adresse du premier de ces octets est l’adresse de l’instruction. À
tout moment, le CPU maintient un registre interne, ip (instruction pointeur ) qui pointe sur
l’instruction en cours d’exécution. Certaines instructions assembleur permettent de modifier la
valeur d’ip, et d’agir ainsi sur le flot du programme.
Sauts simple. L’instruction jmp prends en argument une adresse, et provoque un saut (jump)
dans le programme : l’exécution se poursuit par l’instruction située à l’adresse en question. Cela
correspond à l’instruction GOTO que l’on trouve dans certains langages impératifs.
Lorsqu’un programme est exécuté, il est d’abord chargé par le système en mémoire. L’adresse
à partir de laquelle ce chargement est effectué (et donc l’adresse de chaque instruction !) peut
varier d’une exécution à l’autre. L’encodage de l’adresse d’un saut doit naturellement prendre
cela en compte. Cela peut se faire par un encodage de l’adresse du saut relativement à l’adresse
de l’instruction de saut ou par un mécanisme de relocation. Le code assembleur fourni par gcc
-S comporte en outre des labels (colonne de gauche, suivis de ’ :’) qui repèrent l’adresse de
l’instruction immédiatement suivante.
Appel de sous-fonction. L’instruction call prends en argument une adresse. Elle a deux
effets : d’une part, elle sauvegarde sur la pile l’adresse de l’instruction qui la suit, puis elle effectue
un jmp à l’adresse donnée en argument. Elle est associée à l’instruction ret, qui ne prends pas
d’argument mais effectue un saut à l’adresse donnée par la valeur en tête de pile.
Les drapeaux sont utilisés indirectement au travers de sauts conditionnels, par exemple :
Il existe différentes instructions de saut conditionnel, chacune associée à une condition sur un
ou plusieurs drapeaux.
41
b. Comment se fait l’appel à la fonction sum ?
c. Comment le paramètre (count) est-il transmis ?
d. Comment sum transmet-elle sa valeur de retour ?
Sous-registres. Les registres rax, rbx, rcx, rdx sont des registres 64 bits d’usage générique.
On peut accéder à différentes sous-parties du registre rax au moyen des registres eax, ax, al et
ah selon le diagramme ci-dessous. Ainsi, eax est consitué des 32 bits de poids faible de rax, ax
est constitué des 16 bits de poids faibles de rax, ah est rax
constitué des 8 bits de poids fort de ax, et al de
{
ax
{
ses 8 bits de poids faible. On peut, de même, accé-
ah al
der à différentes sous-parties des registres rbx, rcx et
rdx au moyen de ebx, ecx, edx, bx, cx, dx, bh,
{
bl, ch, cl, dh et dl. eax
On peut accéder aux sous-parties de 32 ou 16 bits de poids faible des registres rdi, rsi,
rbp, rsp par, respectivement, edi, esi, ebp, esp et di, si, bp, sp.
Cette possibilité d’accéder à des sous-parties d’un registre reflète en réalité l’extension pro-
gressive de la capacité des processeurs de la famille x86. Les registres ax, ah, al existaient sur
les processeurs 16 bits. Le registre eax est apparu avec les processeurs 32 bits (le préfixe ’e’
signifie extended ). Le registre rax est apparu avec les processeurs 64 bits.
À ce stade, pour pratiquer, il est conseillé d’écrire des petits morceaux de code en C et
d’examiner de quelle manière ils sont traduits par le compilateur.
42
4.4 Exercices
mov rax,12
mov rax,12
mov rcx,0
mov rax,12 mov rcx,13
bcl: add rax,10
add rax,rax lab: add rax,10
inc rcx
add rax,rax dec rcx
cmp rcx,10
dec rax jnz lab
jle bcl
nop inc rax
inc rax
nop
nop
1 int main() {
2 int i,j=0;
3 for (i=0; i<100; i++)
4 j += i;
5 }
a. Compilez le programme afin d’en faire un objet (option -c). Visualisez le contenu
du fichier .o obtenu gràce à la commande objdump (options -d pour désassem-
bler et -M intel pour utiliser la convention intel).
b. Comptez le nombre d’instructions assembleur et le nombre d’octets occupés en
mémoire par le programme.
c. Donnez le code en langage machine de l’instruction mov rbp,rsp
d. Quelle(s) instructions occupent le plus d’espace mémoire et pourquoi ?
43
Exercice 7 FF Reprenez le code C de l’exercice 1 et générez le code assembleur,
mais cette fois-ci, en compilant avec l’option d’optimisation -O1 (attention, c’est un
‘O’ majuscule, pas un zéro).
a. Dans la fonction sum, comment sont stockées les variables (registre, RAM,
autre) ? Quel est l’intérêt de ce changement ?
b. Observez la partie du code qui correspond au main... Que s’est-il passé ?
c. Modifiez le code en C pour que la fonction sum soit effectivement exécutée.
d. Comparez le reste du code avec ce que l’on avait obtenu sans optimisation, notez
les différences principales (au moins 3) et essayer de les expliquer.
e. Même question en utilisant l’optimisation -O2 (au moins deux différences).
f. Changez la valeur de count pour une valeur (beaucoup) plus petite et compiler
avec -O2. Qu’observe-t-on dans le code assembleur ?
g. Modifiez la fonction sum pour faire une somme de carrés (ou cubes, puissances
quatrièmes, . . . ) et compiler avec -O2. Qu’observe-t-on dans le code assembleur ?
b. Générez le code assembleur de cette fonction, en compilant avec l’option -O1 puis avec
l’option -O2 et comparer.
(a) Quelle est la principale différence entre les deux optimisations ? Quel est l’intérêt ?
c. Refaire les même tests avec la fonction qui calcule les nombres de Fibonacci :
44
int fib(int n){ return (n<=2) ? 1 : fib(n-1) + fib(n-2); }
d. Refaire les même tests avec la fonction suivante, qui calcule aussi les nombres de Fibonacci :
e. Quelle est la différence entre les fonctions fib et fib2 du point de vue de la récursivité ?
45
46
Chapitre 5
Cette 5ème séance introduit à la notion de pipeline dans les processeurs et le problème de la
prédiction de branchement.
23 }
17 } 24 }
25 *l = lo;
26 *u = up;
27 return;
28 }
47
Remarquons que a fait 2n comparaisons là où b en fait 23 n. Cependant, sur certains systèmes et
compilateurs, a est jusqu’à 5 fois plus rapide que b. Pour en comprendre la raison, il nous faut
examiner le fonctionnement du pipeline du processeur.
01 c2 83 c0 01 3d 41 42 0f 00 . . .
83 c0 01 3d 41 42 0f 00 . . .
Principe d’un pipeline. Les différentes étapes ci-dessus sont en général réalisées par des
parties distinctes du processeur. Il n’est donc pas nécessaire d’attendre d’avoir terminé d’écrire
les résultats de l’instruction en cours de traitement pour commencer à décoder l’instruction
suivante. 1 C’est l’idée du pipeline d’instruction : le traitement d’une instruction est décomposé
en une séquence d’étapes élémentaires réalisées par des parties indépendantes du processeur et
ces parties sont mises à travailler à la chaîne. C’est une forme de « Taylorisation » du traitement
des instructions par le processeur.
xor eax,ebx
xor ebx,eax
xor eax,ebx
1. De la même manière que dans le traitement du linge sale par lavage - sêchage - repassage, on attend
rarement d’avoir fini de repasser un premier paquet de linge pour lancer le lavage du second.
48
On ne peut pas lire les opérandes de la seconde instruction tant que le résultat de la première
instruction n’a pas été écrit. Cette séquence d’instruction peut donc provoquer une attente dans
le pipeline, que l’on appelle parfois aussi une « bulle ». De même, toute instruction de saut (jmp,
call, ret, jnz, jge. . . ) provoque une bulle puisque la lecture de l’instruction suivante doit
attendre que soit connue l’adresse à laquelle se continue le programme.
• Stage 1 decode examine les premiers octets de l’instruction suivante (appellés opcode
et mod r/m) pour déterminer l’instruction, les types des opérandes (registre, mémoire,
immédiat) et leur taille (8, 16, 32 ou 64 bits).
• Stage 2 decode récupère les opérandes immédiates et effectue les calculs d’adresses
mémoires en cas de déplacement (ex : [rbp - 8]).
• Register write-back met à jour les registres suite à l’exécution de l’instruction (registre
destination s’il y en a, rsp en cas de push ou pop, etc.), de la mémoire (si la destination
est en mémoire) et le registre flags s’il est affectés.
Le nombre de cycles pris pour le traitement d’une instruction dans un niveau peut varier pour
différentes raisons : lecture en/hors cache lors de l’instruction prefetch, le nombre d’octets d’op-
code varie selon les instructions (chacun occupe le Stage 1 decode pour 1 cycle), etc.
Bulles. Les différents niveaux peuvent travailler en parallèle. Il arrive cependant qu’un niveau
doive, pour traiter son instruction courante, attendre le résultat du traitement par un niveau
postérieur d’une instruction précédente. Reprenons l’exemple :
1 0 31 D8 xor eax,ebx
2 2 31 C3 xor ebx,eax
3 4 31 D8 xor eax,ebx
Ici, l’exécution du second xor nécessite de connaître la valeur prise par eax suite au premier
xor. Cette valeur n’est connue qu’après le write back du premier xor. Il y a donc un temps
d’attente, c’est à dire une bulle : le niveau execution attend sans rien faire que le niveau
write back termine sa tâche, et cette attente se répercute dans tout le pipeline en amont
d’execution. Les instructions de saut, quant à elles, peuvent produire deux types de bulles :
• l’adresse de saut peut être déterminée après decode 2 pour les instructions comme jmp
ou call sautant à des adresses immédiates.
• l’adresse de saut n’est déterminée qu’après execution pour des instructions comme ret
pour lesquelles l’adresse de saut est lue en mémoire, ou les sauts conditionnels pour lesquels
il faut évaluer la condition avant de savoir si on prends ou pas le saut.
49
Réduction des bulles. Il est parfois possible de permuter/modifier les instructions de manière
à éliminer les bulles sans changer le résultat du calcul. Voici un exemple :
Exécution spéculative. Lors d’un saut conditionnel, on peut déterminer dès le décodage
l’adresse à laquelle se fait le saut et on n’attends la fin de l’exécution que pour savoir si il se
fait. La complexification des pipelines accroît la différence entre attendre la fin du décodage et
attendre la fin de l’exécution. Dès 1993, les processeurs intel traitent chaque saut conditionnel
comme suit :
• Lors du décodage, un pari est fait sur la valeur (vraie ou fausse) que prendra la condition
de saut. Ce pari est calculé par un prédicteur de branchement.
• En attendant de connaître la valeur effectivement prise par cette condition, le saut est
considéré comme pris ou non pris selon le pari. Cela détermine donc quelles instructions
sont lues et décodées. Le traitement de ces exécutions est donc lancé spéculativement.
• Une fois la condition effectivement évaluée, on la compare au pari. En cas de pari gagné,
l’exécution continue normalement. On a réussi à réduire la bulle. En cas de pari perdu, on
vide le pipeline car il faut recommencer (lecture, décodage, etc.) à partir de l’instruction
qui suit le saut conditionnel (dans la branche qu’il faut effectivement suivre).
Ainsi, en cas de pari réussi un saut conditionnel est géré comme un saut inconditionnel. La
rapidité d’exécution du code dépends donc de la proportion de paris gagnés. On parle de pénalité
de prédiction erronée (de l’ordre de 15-20 cycles sur les architectures de type Skylake).
50
Le biais est une bonne nouvelle. La prédiction de branchement est en fait de la détection
de motifs dans l’exécution du programme. Des approches existent à base de compteurs saturants,
de tables d’historiques, de réseaux de neurones, . . . En première approximation, il est raisonnable
d’escompter que leur efficacité sera d’autant plus grande que le branchement à prédire est biaisé.
Réexaminer l’exemple initial par ce prisme devrait être éclairant.
5.5 Exercices
(a) On suppose que le prédicteur est initialement dans l’état 1. Indiquez les indices du
tableau pour lesquels le prédicteur de branchement fait une erreur de prédiction.
(b) Même question en supposant que le prédicteur est initialement dans l’état 4.
51
Exercice 2 FF Cet exercice vise à mettre en évidence l’influence du pipeline sur
l’exécution d’une boucle comportant un saut conditionnel.
a. Réalisez cette expérience (en pensant à compiler sans optimisation, i.e. en -O0). Si les
résultats observés dévient substantiellement de ceux annoncés, vérifiez le code machine
produit par gcc.
(a) Votre expérience est-elle concluante ?
(b) Quel facteur multiplicatif mesurez-vous entre les temps minimum et maximum pris
par la fonction ?
(c) Comment expliquez-vous cette observation ?
b. Ce phénomène persiste-t-il si on compile en -O1 ? Examinez le code assembleur produit
par gcc. Comment expliquez-vous cela ?
if (v>24) if (v<51)
if (v<51) et if (v>24)
j++; j++;
52
Observation. L’une devrait être sensiblement plus ra-
pide que l’autre.
Oui Non
Si les résultats observés dévient substantiellement de ceux annoncés, vérifiez le code ma-
chine produit par gcc.
c. Quel facteur multiplicatif mesurez-vous entre les temps de ces deux fonctions ?
d. Comment expliquez-vous cette différence de rapidité ?
e. Ce phénomène disparaît-il si on compile en -O1 ou -O2 ? Comment cela s’explique-t-il ?
1 #include <stdio.h>
2 int main() {
3 int i, j=0, k=0, l=0, res=0;
4 for (i=1; i<10; i++){
5 j+=i*i*i*i;
6 k+=j*j*j*j;
7 l+=j*j*k*k;
8 res+=j/k;
9 res+=l;
10 }
11 printf("%d\n",res); return 0;
12 }
Compilez ce code en -O1 et -O2 et visualisez, côte à côte, les fichiers .s après les avoir
nettoyés pour ne garder que les instructions. Identifiez trois différences dans le code
que vous savez expliquer.
53
54
Chapitre 6
Spectre et meltdown
Cette 6ème séance examine les failles de sécurité Spectre et meltdown. Ces deux failles
exploitent certains comportements des mécanismes de hiérarchie mémoire et d’exécution spé-
culative. L’examen théorique de leurs principes va nous permettre de mettre en application
les notions vues jusqu’à présent. La mise en œuvre pratique de ces principes, au travers d’une
ébauche de preuve de concept, va nous amener à revisiter de manière plus pointue le chronomé-
trage de code et l’analyse de fonctionnement du processeur.
Une attaque sur un système informatique est la réalisation d’une action non autorisée. Cette
action peut viser à modifier le système (par exemple crypter un fichier) mais peut aussi sim-
plement viser à l’observer (lecture de données, de clefs cryptographiques, etc.). Une attaque par
canal auxiliaire (« side-channel attack ») est une attaque qui tire parti de défauts dans non
pas la conception, mais dans l’implantation d’un système informatique. Une attaque par canal
auxiliaire peut notamment s’appuyer sur la mesure d’effets secondaires du fonctionnement d’un
système informatique (par exemple la consommation électrique ou les bruits émis, . . . ). Parmi
ces attaques par canaux auxiliaires, les attaques temporelles (« timing attacks ») déduisent des
informations sur un système informatique à partir de mesures de ses temps de réponse.
Les attaques par canaux auxiliaires exploitent un écart entre les spécifications d’un système
et son implantation. Soulignons qu’un tel hiatus est inévitable. Les spécifications sont faites dans
un modèle formel qui doit permettre de raisonner abstraitement sur le comportement global du
système (par exemple de prouver qu’il accomplit correctement les tâches pour lesquelles il a été
conçu et ne peut se retrouver dans un état problématique). Ce modèle abstrait ne traduit qu’une
partie des caractéristiques du monde physique dans lequel se situe l’implantation du système.
Les attaques par canaux auxiliaires exploitent précisément les « impensés » du modèle abstrait,
c’est à dire les effets physiques qu’il ne modélise pas.
55
On restera ici à un niveau d’analyse qui ne distingue pas ces deux failles. Spectre et Meltdown
réalisent inpunément un accès mémoire interdit en combinant 2 idées.
mov eax, -1
cmp esi, edx
int tab[500]; jge .L24
... movsx rsi, esi
if (i<500) movzx eax, BYTE PTR [rdi+rsi]
a+=tab[i] .L24:
rep ret
.cfi_endproc
Comme on l’a vu, ce if se traduit en code assembleur par un saut conditionnel. Le fonction-
nement en pipeline du processeur devrait amener à un temps d’attente (« bulle ») important
car le simple chargement de l’instruction suivant ce saut conditionnel devrait attendre l’évalua-
tion de la condition présidant au saut. Le mécanisme d’exécution spéculative contourne cela en
choisissant (via le prédicteur de branchement) un des résultats comme le plus probable, et en
l’exécutant sans attendre. En cas d’erreur, on jette le travail effectué et on reprend l’exécution
à partir du saut, en choisissant cette fois la bonne alternative.
Imaginons que dans le code ci-dessus, on se présente à l’instruction if avec une valeur
i = 10 000. Il est possible que le prédicteur de branchement prédise, à tort, que la comparaison
donnera un résultat vrai. Dans ce cas, ĺ’instruction a+=tab[i], ou plus précisément sa traduction
en assembleur, est exécutée spéculativement ; en particulier, le processeur accède en lecture à
l’adresse tab + 10 000. Une fois la condition du if correctement évaluée, le processeur fera
machine arrière et « oubliera » la valeur lue à l’adresse tab + 10 000.
Que se passe-t-il si l’adresse tab + 10 000 se trouve dans une zone mémoire à laquelle le
programme n’a pas le droit d’accéder ? Si un tel accès était fait directement, il produirait une
erreur au niveau du système qui se traduirait par une interruption brutale du programme et
l’affichage d’un familier segmentation fault. Dans le cas où un tel accès est effectué lors d’une
exécution spéculative erronée, il est raisonnable qu’il ne soit pas sanctionné car il ne correspond
pas à une instruction qu’aurait dû effectuer le programme.
56
cet effet n’est pas annulé et laisse donc des traces. Ainsi, dans notre exemple ci-dessus, si la
lecture du contenu de l’adress tab + 10 000 lors d’une exécution spéculative erronée a provoqué
le chargement d’un bloc en cache, l’annulation de cette exécution spéculative laisse ce bloc en
cache.
Contrôler la pagination. Une première difficulté consiste à éviter que les mécanismes de
gestion mémoire du système n’interfèrent avec nos chronométrages, et en particulier que la zone
mémoire utilisée pour notre tableau T ne se retrouve reléguée dans l’espace « swap ». Pour cela,
on peut utiliser les commandes système mlock et munlock dès la réservation de notre tableau.
#include <sys/mman.h>
...
mlock(p,taille);
munlock(p,taille);
Vider le cache. Une première contrainte est qu’il faut s’assurer que les blocs de notre tableau
T sont tous déchargés de tous les niveaux de cache. Une solution générique (« eviction ») consiste
à déclencher suffisamment d’accès licites sollicitant les mêmes sous-caches que le mécanisme de
pagination expulse naturellement les blocs de T . Une solution plus simple existe parfois sous
la forme d’une instruction de « déchargement » ; c’est le cas sur les processeurs Intel, qui
proposent l’instruction assembleur clflush. Cette instruction est disponible depuis le C de la
même manière que l’instruction rdtscp :
57
#include <x86intrin.h>
...
_mm_clflush(p);
Cette instruction évacue de tous les niveaux de cache le bloc qui contient l’élément d’adresse p.
C’est ce que l’on utilisera.
#include <x86intrin.h>
...
_mm_mfence();
Éviter les accès mémoire parasites. On ne contrôle pas l’adresse mémoire à laquelle se
trouvera logé le tableau T . En particulier, il est envisageable qu’il n’occupe pas entièrement
le bloc contenant son premier ou son dernier octet. Cela signifie que ces blocs pourraient être
accidentellement chargés en cache par des accès mémoire qui n’ont rien à voir avec T . Pour éviter
cela, il convient d’ajouter un tampon au début et à la fin de T : il s’agit d’une zone mémoire
que l’on réserve conjointement avec T avec pour seul but d’isoler la partie utile de T du reste
de la mémoire.
Et encore d’autres optimisation... D’autres mécanismes peuvent interférer avec nos me-
sures. Par exemple, si on chronomètre le temps pris pour accéder aux blocs de T dans l’ordre, nos
mesures risquent d’être perturbées par le stride prediction, un circuit dédié à l’anticipation
des espacements entre accès mémoires et qui peut, préventivement, charger en cache des blocs
dont il estime l’accès probable. On va donc chronométrer les accès à nos blocs dans un ordre
suffisamment compliqué pour feinter ce prédicteur. Par exemple :
L’idée ici est que i 7→ (i * 167) + 13) & 0xff est une permutation de l’ensemble {0, 1, . . . , 255}
qui s’avère difficile à anticiper pour les processeurs actuels.
58
6.4 Exercices
Passons donc à la pratique. On découpe la réalisation d’une ébauche de preuve de concept de
Spectre/Meltdown en étapes qui sont autant d’exercices. Les exercices qui se suivent se cumulent
donc. Chacun ajoute de nouvelles fonctions au programme que l’on construit, et il ne faut pas
passer à l’exercice suivant tant que l’exercice en cours n’est pas résolu de manière concluante.
a. Dans le tableau T , on ne va pas travailler directement avec des blocs mais avec
des octets. On va ainsi identifier 256 octets, que l’on appelle utile, et qui auront
la spécificité de se trouver dans des blocs deux à deux distincts. Pour plus de
sécurité, on va même souhaiter que deux octets utiles consécutifs se trouvent
dans des blocs non seulement distincts, mais aussi non consécutifs.
Définissez une constante PAS et initialisez la à ce qui vous semble un bon écar-
tement entre les adresses de deux octets utiles consécutifs.
b. Définissez une constante TAMPON et initialisez la à ce qui vous semble un nombre
raisonnable d’octets pour isoler le tableau T de part et d’autre.
c. Réservez en mémoire, par malloc, le tableau T en définissant son type comme
unsigned char *. Assurez-vous par mlock que chaque octet utile est chargé en
RAM.
d. Écrivez une fonction vide_cache qui s’assure, via clflush, qu’aucun des octets
utiles de T ne se trouve en cache. Terminez votre fonction par un _m_mfence().
e. Écrivez une fonction chrono qui chronomètre l’accès à chacun des octets utiles.
Cette fonction prendra en entrée le tableau T et un pointeur sur un tableau
de 256 int, et écrira les temps d’accès dans ce second tableau. N’oubliez pas
de mesurer les temps d’accès aux octets utiles dans un ordre qui empêche le
stride predictor d’interférer.
59
f. Testez l’ensemble en appelant vide_cache, puis en accédant en lecture à un
octet utile pour en faire quelque chose, puis en appelant chrono pour vérifier
que l’accès à cet octet apparaît bien plus rapide. Déterminez les différentes
mesures type obtenues.
g. Moyennez les résultats de votre expérience sur 100 tentatives.
En cas de résultats insatisfaisants, il ne faut pas hésiter à expérimenter avec les
valeurs de PAS et TAMPON.
Exercice 2 FF
a. Déclarez un tableau tab de 100 unsigned char et initialisez le avec des valeurs
arbitraires.
b. Écrivez une fonction victime qui prend en entrée un pointeur tab de type
unsigned char * et deux int max et i, qui lit T[TAMPON + tab[i]*PAS] si i <
max et qui ne fait rien sinon.
c. Vérifiez, au moyen des outils préparés à l’exercice 1, qu’un appel « normal » à
votre fonction victime, c’est à dire avec i<max, laisse une trace mesurable en
cache.
Exercice 3 FF
a. Déclarez deux variables, l’une unsigned char secret = 54 et l’autre size_t
index = &secret-tab. Remarquez qu’un accès à tab[index] est autorisé et
renvoie bien la valeur 54.
b. Faites un appel à victime avec i = index et examinez si cela laisse un effet
visible en cache.
c. Faites une série d’appels à victime avec une valeur i<max pour entrainer le
prédicteur de branchement associé au if à prédire que la branche sera prise,
puis faites un appel à victime avec i = index. Vérifiez que ce dernier appel
laisse une trace en cache.
À ce stade, on pourrait remplacer size_t index = &secret-tab par size_t index = TOTO
- tab pour réussir à deviner le contenu de l’octet qui se trouve à l’adresse TOTO, que l’on ait
le droit d’y accéder ou pas...
Pour un article de vulgarisation qui décrit un peu plus en détail les principes de Spectre et
Meltdown et des possibilités de leur mise en œuvre, voir :
60
• Spectre Attacks : Exploiting Speculative Execution. Kocher et al. Communication
of the ACM, juillet 2020.
https://cacm.acm.org/magazines/2020/7/245682-spectre-attacks/fulltext
61
62
Chapitre 7
Introduction à la vectorisation
Cette dernière séance introduit à la notion de vectorisation de code. L’objectif est de pouvoir
appréhender le type de problèmes traitables efficacement sur GPU.
SISD. Dans la gamme intel, les premiers processeurs ont été conçus pour exécuter séquentiel-
lement des instructions ne traitant qu’une seule donnée. On parle de processeurs SISD (single
instruction single data). L’introduction de pipelines (cf séance 5) semble permettre d’exécuter
plusieurs instructions en parallèle, mais il s’agit seulement d’utiliser plus efficacement chaque
partie du processeur ; comme chaque partie continue à traiter les instructions séquentiellement,
un processeur à pipeline reste SISD. Ce modèle a évolué de deux manières.
MIMD. Une première forme de réel parallèlisme est le MIMD (multiple instruction multiple
data). Il s’agit d’exécuter plusieurs instructions distinctes simultanément. En pratique, cela peut
se réaliser en construisant plusieurs pipelines, éventuellement avec un pipeline principal capable
de traiter toutes les instructions et un pipeline secondaire, allégé, capable de traiter certaines
instructions simples et fréquentes.
Par exemple, le pentium (1993) comporte deux pipelines appelés U et V. Lors du décodage,
certaines instructions sont appariées pour être traitées en parallèle, l’une par U et l’autre par V.
Ces appariements doivent respecter certaines contraintes :
• Les instructions qui peuvent s’apparier dans U ou dans V sont mov, push, pop, inc,
dec, add, sub, cmp, and, or, xor.
• Les instructions qui ne peuvent s’apparier que dans U sont adc, {shl, shr, sal, sar}
avec un compteur immédiat, et {ror, rol, rcr, rcl} avec un compteur de 1.
Les autres instructions ne peuvent pas être appariées. Les appariements doivent par ailleurs
respecter des règles d’indépendance (qu’on ne détaille pas ici), comme par exemple de travailler
sur des registres différents.
63
SIMD. Une seconde forme de réel parallèlisme est le SIMD (single instruction multiple data)
qui exécute les instructions une par une mais où chaque instruction opère sur plusieurs données
à la fois. De manière équivalente, il s’agit de travailler sur des vecteurs en applicant la même
opération composante par composante. Le reste de la séance se concentre sur l’architecture de
type SIMD. On la retrouve notamment sur les processeurs graphiques (GPU).
Vocabulaire. Les processeurs SIMD travaillant sur des vecteurs, on les appelle parfois pro-
cesseurs vectoriels. Par analogie, les processeurs SISD et MIMD travaillent sur des scalaires.
On appelle les SISD des processeurs scalaires et les MIMD des processeurs superscalaires. En
pratique, ces distinctions ne sont pas forcément pertinentes au niveau des processeurs, les proces-
seurs intel actuels comportant par exemple des parties superscalaires et des parties vectorielles.
Solution “naturelle”. Voici une première solution et le code produit par gcc -O2 :
Une quasi-solution vectorisée. Dans la solution “naturelle”, les données sont lues et écrites
en mémoire octet par octet (cf lignes 4 et 5 du code asm). Il peut être tentant d’utiliser le fait
que les processeurs 64 bits peuvent travailler sur 8 octets simultanément comme ceci :
Passons pour l’instant sur le fait que cette fonction pincr ne traite que des chaîne de caractères
dont la longueur est un multiple de 8 (on pourrait gérer les caractères restants un par un).
1. Si la chaîne contenait un caractère de code 255 elle se retrouvera donc tronquée. On ne s’en souciera pas.
64
Pourquoi vectorisée ? L’instruction add de la ligne 6 simule du parallèlisme SIMD. En effet,
ajouter 72340172838076673 = 0x0101010101010101, à la valeur QWORD PTR [rdi] revient à
ajouter 1 à chacun des 8 entiers 8-bits stockés en mémoire à l’adresse [rdi]. Au niveau du
circuit additionneur, les différentes composantes 8-bits de QWORD PTR [rdi] sont traitées en
parallèle. Ces opérations sont indépendantes.
Pourquoi quasi ? Remarquons que le traitement d’un des octets peut déborder sur un autre
octet à cause des retenues. Par exemple, si *p vaut ...05 FF avant traitement, il vaudra ...07
00 après traitement et non pas ...06 00. L’idée de la vectorisation des processeurs est d’ajouter
des registres et des instructions qui permettent de travailler sur des vecteurs de nombres, comme
ci-dessus, en évitant les problèmes de débordement.
Calcul vectoriel. Le calcul sur des vecteurs de nombres, ou calcul vectoriel, est facilement pa-
rallélisable puisque les différentes composantes sont indépendantes. Pour tirer parti de ces outils
vectoriels, et par exemple “faire tourner un programme sur GPU”, il faut (ré)écrire l’algorithme
en terme de vecteurs.
65
Un premier exemple. Revenons à notre fonction incr ci-dessus. Si on la compile en -O3, on
obtient un code assembleur sans instruction vectorielle. Cela s’explique par exemple par le fait
que le nombre d’itérations n’est pas facilement prévisible. En revanche, gcc réussit à vectoriser :
{
que les calculs de débordent pas d’un bloc à l’autre. Les re-
gistres vectoriels ont été créés initialement en 128 bits (1999), xmm.
puis ont été étendus à 256 (2011) puis à 512 bits (2013). Ils
{
sont organisés comme indiqué ci-contre, le “.” étant un index ymm.
allant de 0 à 31.
Par exemple, les registres xmm0 à xmm31 sont 128 bits et peuvent être utilisés comme des
vecteurs d’entiers 64 bits (2 coordonnées), 32 bits (4 coordonnées), 16 bits (8 coordonnées) ou 8
bits (2 coordonnées) ; ils peuvent aussi être utilisés comme des vecteurs de nombres flottants 64
bits (2 coordonnées) ou 32 bits (4 coordonnées).
Instructions vectorielles. Les registres vectoriels sont utilisés par des instructions spéci-
fiques, et souvent assez spécialisées pour le traitement de grandes quantité de données. Un
descriptif complet des instructions x86 (instructions vectorielles comprises) est disponible à
https://www.felixcloutier.com/x86/
Par exemple, l’instruction pavgw xmm0,xmm1 calcule la moyenne par composantes de 16 bits,
avec arrondi supérieur. Cela revient à additionner ces deux vecteurs puis à décaler chaque com-
posante (shr de 1). L’instruction pavgb xmm0,xmm1 fait de même, mais en considérant xmm0 et
xmm1 comme des vecteurs dont les composantes sont 8-bits.
On retrouve ce niveau de spécialisation des circuit dans les GPU, cf par exemple les tensor cores
de la microarchitecture volta des cartes Nvidia. 2
En calcul vectoriel, il reste possible d’appliquer à chaque scalaire un flot non-linéaire mais
les outils ont changé. En assembleur scalaire, on a vu que cela pouvait se faire au travers des
flags et de sauts conditionnels, voire d’instructions de transfert conditionnel du type movc. En
assembleur vectoriel, on peut n’appliquer une instruction qu’à certaines composantes par du
masquage.
66
0, selon qu’elle était supérieure ou inférieure à la composante correspondante dans le vecteur
source. Ce vecteur de 0 et −1 permet ensuite, par masquage via pand, de sélectionner l’une ou
l’autre famille de composantes.
7.5 Exercices
Les exercices 1 et 4 demandent d’écrire des algorithmes vectorisés. On utilisera pour cela
les conventions suivantes. On utilise des vecteurs pouvant contenir 4 entiers (qu’on appelle
des composantes). Chaque composante est traitée comme un int. Il est inutile de déclarer les
variables vecteurs. On autorise les opérations suivantes sur les vecteurs.
• initialiser un vecteur (ex : u = 1,1,1,1 et v = 0,-33,42,806 )
• additionner (ou soustraire, multiplier, diviser) deux vecteurs composante par composante
(ex : w = u + v . Avec les valeurs précédentes, w vaut 1,-32,43,807)
• faire la somme des 4 composantes d’un vecteur (ex : int i = sum_comp(w) ; avec les
valeurs précédentes, on obtient que i vaut 819)
67
sum: jle .L2 .p2align 3
test edi, edi add eax, ecx .L9:
jle .L9 lea ecx, [rdx+2] xor eax, eax
lea eax, [rdi-4] cmp edi, ecx .p2align 4,,10
lea ecx, [rdi-1] jle .L2 .p2align 3
shr eax, 2 add eax, ecx .L2:
add eax, 1 lea ecx, [rdx+3] rep ret
cmp ecx, 8 cmp edi, ecx .p2align 4,,10
lea edx, [0+rax*4] jle .L2 .p2align 3
jbe .L10 add eax, ecx .L13:
pxor xmm0, xmm0 lea ecx, [rdx+4] rep ret
movdqa xmm2, ... .LC1 cmp edi, ecx .p2align 4,,10
xor ecx, ecx jle .L2 .p2align 3
movdqa xmm1, ... .LC0 add eax, ecx .L10:
.L4: lea ecx, [rdx+5] xor edx, edx
add ecx, 1 cmp edi, ecx xor eax, eax
paddd xmm0, xmm1 jle .L2 jmp .L3
paddd xmm1, xmm2 add eax, ecx .LFE21:
cmp eax, ecx lea ecx, [rdx+6] .size sum, .-sum
ja .L4 cmp edi, ecx ...
movdqa xmm1, xmm0 jle .L2 .LC0:
cmp edi, edx add eax, ecx .long 0
psrldq xmm1, 8 lea ecx, [rdx+7] .long 1
paddd xmm0, xmm1 cmp edi, ecx .long 2
movdqa xmm1, xmm0 jle .L2 .long 3
psrldq xmm1, 4 add eax, ecx .align 16
paddd xmm0, xmm1 add edx, 8 .LC1:
movd eax, xmm0 lea ecx, [rax+rdx] .long 4
je .L13 cmp edi, edx .long 4
.L3: cmovg eax, ecx .long 4
lea ecx, [rdx+1] ret .long 4
add eax, edx .p2align 4,,10 .align 16
cmp edi, ecx
68
Exercice 2 F Voici quelques boucles dont on se demande si elles sont vectorisables.
Commencez par indiquer pour chacune d’entre elle si vous réussissez à la vectoriser
à la main (comme à l’exercice 1). Ensuite, et ensuite seulement, vérifiez si gcc
y parvient. Pour ces vérifications, compilez le code en -O3, prenez soin d’écrire les
boucles dans des fonctions.
Exercice 3 FFF
a. Écrivez une fonction C qui calcule le minimum d’un tableau d’entiers donné
en argument. Compilez cette fonction par gcc -O3 et examinez l’assembleur
obtenu.
b. Examinez ce que font les instructions vectorisées pcmpgtd, pand, pandn. Propo-
sez un code assembleur qui prend en entrée deux registres xmm0 et xmm1, consi-
dérés comme des vecteurs de doubles, et calcule leur minimum composante par
composante.
c. Si le code obtenu à la question (a) est vectorisé, décortiquez-le et résumez-en
les principes.
69
Codage des images. Il est courant de décrire la couleur d’un pixel 3 par trois
valeurs : une composante rouge, une composante verte et une composante bleue.
Une norme largement utilisée actuellement, le truecolor, décrit chacune de ces trois
composantes par un entier 8 bits (char). Ainsi, le triplet (0, 0, 0) désigne le noir, le
triplet (255, 255, 255) désigne le blanc, et (x, 0, 0) désigne un rouge qui est vif si x
est proche de 255 et sombre si x est proche de 0.
Niveaux de gris. En truecolor, une couleur est un niveau de gris si les trois
composantes sont égales. Pour convertir une image en niveau de gris, une méthode
consiste à remplacer, pour chaque pixel, les trois composantes par leur moyenne :
ainsi, on remplace le triplet (50, 70, 180) par (100, 100, 100) puisque 50+70+180
3 = 100.
Structure de données. On numérote les pixels de l’image ligne par ligne, en com-
mençant en haut à gauche. On veut stocker une image de taille LARGEUR×HAUTEUR,
donc on réserve le tableau suivant :
70
Solution A Solution B
71