Poly Archi2022

Télécharger au format pdf ou txt
Télécharger au format pdf ou txt
Vous êtes sur la page 1sur 71

Architecture des ordinateurs

Responsable du cours : Xavier Goaoc

FICM 2A Cours de département informatique 2021–2022


2
À lire avant la première séance

Ce cours d’architecture est à aborder comme un cours d’informatique expérimentale. Nous


allons mettre évidence des phénomènes surprenants par l’observation du comportement de pro-
grammes simples et courts. Pour expliquer ces phénomènes, nous introduirons des concepts, que
nous mettrons ensuite à l’épreuve de nouvelles expériences.

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.

À chaque séance, vous travaillerez par groupes de 3 ou 4, en faisant régulièrement valider


votre progression par l’enseignant.

Le programme du cours est le suivant.


• La première séance fait un rapide rattrapage de notions de base en architecture : circuits
arithmétique, arithmétique entière naturelle et relative, arithmétique flottante et norme
IEEE 754. Cette séance introduit plus de notions que les autres, mais ces notions sont plus
élémentaires.
• La séance 2 présente une méthodologie de chronométrage précis de code et l’utilise pour
mettre en évidence quelques contre-performances qui peuvent se produire lors de lec-
tures/écritures en mémoire.
• La séance 3 présente les principes de hiérarchie mémoire (à associativité totale) et s’en sert
pour expliquer les phénomènes observés à la 2ème séance. On présente ensuite un modèle
plus précis (à associativité partielle) et on met en place une expérience qui met en évidence
son importance.
• La séance 4 est consacré aux principes de l’assembleur x86. L’objectif est d’en assimiler
suffisamment pour pouvoir disséquer le résultat de la compilation de petits codes par gcc.
• La séance 5 présente le principe de pipeline (au travers du pipeline 5-niveaux du 80 486)
et aborde l’exécution spéculative au travers de la prédiction de branchement.
• La séance 6 examine de quelle manière les failles de sécurité Spectre et Meltdown tirent
parti des comportement des hiérarchies mémoire et de la prédiction de branchement.
• La dernière séance introduit à la vectorisation d’algorithme et l’examen de codes vectorisés
(toujours produits par gcc, sur intel 64 bits).

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

• Les manuels d’optimisation d’Agner Fog :

https://www.agner.org/optimize/

Remerciements. Ce cours a été construit conjointement avec Carine Pivoteau (Université


Gustave Eiffel). Je tiens par ailleurs à remercier Jérémie Detrey dont les conseils, suggestions
et explications m’ont été très précieux. Les erreurs, coquilles et autres approximations sont bien
entendu entièrement de mon fait.

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

2 Modèle plat de la mémoire et chronométrage 19


2.1 Modèle de mémoire plate . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
2.1.1 La mémoire est un tableau . . . . . . . . . . . . . . . . . . . . . . . . . . 19
2.1.2 Allocation mémoire : deux mécanismes . . . . . . . . . . . . . . . . . . . . 20
2.1.3 Endianness . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
2.2 Chronométrage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
2.2.1 Problématique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
2.2.2 L’outil : rdtscp . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
2.2.3 Limitations et biais . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
2.2.4 Compléments . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
2.3 Exercices . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24

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

4 Notions d’assembleur x86 37


4.1 Généralités . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
4.2 Obtention du code assembleur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
4.3 Quelques notions d’assembleurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
4.3.1 Les bases . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
4.3.2 Gestion de flot . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
4.3.3 Plus en détail . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
4.4 Exercices . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43

5 Pipeline et prédiction de branchement 47


5.1 Problématique : un peu d’algorithmique . . . . . . . . . . . . . . . . . . . . . . . 47
5.2 Principe d’un pipeline . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 48
5.3 Exemple de pipeline à 5 niveaux . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
5.4 Gestion des sauts conditionnels . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50
5.5 Exercices . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51

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

Commençons par une petite expérience :

• Déclarez float a = 0.1, b = 0.2, c = 0.3 et


examinez ce que vaut le booléen a+b == c (vrai
ou faux ?).
• Réitérez l’expérience en déclarant les variables en
double .
• Réitérez ces deux expériences avec a = 1.1, b =
1.2, c = 2.3;

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.1 Circuits (pour la culture)


Commençons par examiner les principes qui sous-tendent l’automatisation du calcul, et donc
la manière dont un processeur calcule.

1.1.1 Binaire et Booléen


La première idée sur laquelle les processeurs sont basés est la suivante :

Il est possible de traduire l’arithmétique binaire en calcul Booléen.

1. https://en.wikipedia.org/wiki/Sleipner_A
2. https://en.wikipedia.org/wiki/Ariane_5

7
Explicitons cela.

D’une part, tout entier naturel x peut s’écrire


k−1
X
x= ai 2i avec ai ∈ {0, 1}
i=0

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

pour des exemples.

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).

Exercice 1 F Toujours avec la convention 0 ↔ faux et 1 ↔ vrai, que vaut 0 ∧ 1 ?

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 .

Exercice 2 FF Donnez de la même manière c0 en comme une fonction Booléenne


de a0 et b0 .

8
La deuxième idée pour mécaniser le calcul est la suivante :

Pour chaque expression en calcul Booléen il existe un système phy-


sique qui l’évalue.

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).

1.1.2 Circuit additionneur


Voilà, on sait construire un système physique qui calcule une addition entre deux nombres
1-bits. Or, l’addition de deux entiers naturels arbitraires, donnés par leurs représentations bi-
naires, peut se faire par une séquence d’additions de nombres 1-bits. On peut donc enchaîner
les fonctions Booléennes que l’on vient de voir (ou les circuits électriques, selon les goûts) pour
obtenir des fonctions Booléennes encodant l’addition entre entiers naturels.

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) :

Notre additionneur 2-bits ressemble donc à ceci :

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.

1.1.3 Et pour quelques circuits de plus...


Nous n’en dirons pas plus dans ce cours sur les circuits. Pour les volontaires souhaitant
prolonger, un exercice intéressant consiste à concevoir, de la même manière, un multiplexeur, qui
prends en entrée des signaux numérotés 1, 2, . . . et k bits de sélection a0 , a1 , . . . ak−1 , et produit
en sortie le signal N o (ak−1 ak−2 . . . a0 )2 . Le nombre de signaux d’entrée est au plus 2k ; s’il
est inférieur à 2k , on n’utilise qu’une partie de l’adressage offert par les k bits de sélection. À
l’inverse, le démultiplexeur prends en entrée un signal et des bits de sélection, est connecté à
plusieurs sorties, et dirige le signal vers la sortie indiquée par les bits de sélection.

1.2 Calcul entier


Les principes vus en Section 1.1 guident la réalisation du circuit arithmétique d’un proces-
seur classique. Sans entrer dans les nombreuses complications techniques 3 , nous allons en tirer
quelques contraintes générales qui s’imposent à tout 4 processeur calculant en entiers.

1.2.1 L’arithmétique est finie


Tout d’abord, un circuit réalise le calcul d’une fonction Booléene et manipule donc l’infor-
mation en binaire. On mesure donc la taille d’un objet informatique (par exemple un entier) au
nombre de bits qu’il faut pour le coder.

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.

3. Par exemple : quel algorithme de multiplication est le plus intéressant à « cabler » ?


4. Bon, presque tous : rien de ce que l’on décrit ici ne s’applique aux embryons d’ordinateurs dits « quan-
tiques ».

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.

1.2.2 Entiers signés et non signés


Au niveau électronique, un processeur b bits manipule des séquences de b bits, que l’on appelle
mots machines. Un mot machine peut naturellement s’interprèter comme l’écriture binaire sur
b bits d’un entier naturel. Si l’on souhaite travailler sur des entiers relatifs, il nous faut fixer de
même une convention de codage.

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 .

Exercice 5 F Donnez le codage, par cette règle, des entiers relatifs 0, 1, −1 et −2


sur 4 bits puis sur 8 bits.

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 :

On peut utiliser le même circuit additionneur pour le codage d’entiers


naturels, et pour le codage d’entiers relatifs par complément à deux,
mais pas pour le codage d’entiers relatifs par 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é.

Exercice 7 FF Prouver que dans la règle du complément à deux, le bit de poids


fort du codage d’un entier relatif x vaut 0 si x ≥ 0, et 1 si x < 0.

1.2.3 Le typage est une vue de l’esprit (ou du compilateur)


Un entier non-signé (resp. signé) b-bits désigne un mot binaire de b bits interprété comme
le codage d’un entier naturel (resp. relatif). Le contenu d’une case mémoire (ou d’un groupe
de cases mémoires) peut être librement interprété comme un entier signé ou non-signé : cette
interprétation n’est attachée ni à la case mémoire, ni au mot binaire qui y est stockée. Elle
n’existe, en fait, que lorsque l’on exécute une instruction qui interprète ce mot d’une manière ou
d’une autre.

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.

Exercice 8 FF Exécutez (séparément) chacun des code suivants (qui supposent


chargées les bibliothèques stdio.h et string.h) et expliquez ce qu’ils font.

unsigned char c = 150;


unsigned char* p;
unsigned char c = 150;
signed char* q;
signed char d;
p = &c;
memcpy(&d, &c, 1);
memcpy(&q, &p, sizeof(p));
printf("%hhi\n", d);
printf("%hhu\n", *p);
printf("%hhi\n", *q);

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é.

1.3 Encodage de nombres à virgule


Cette section clarifie quelques notions liés aux nombres à virgule. Pour simplifier la présen-
tation on ne s’intéresse qu’aux nombres positifs.

1.3.1 Nombres représentables


Quelque soit la convention de codage, comme il y a 2b mots binaires distincts sur b bits, on ne
peut coder qu’au plus 2b nombres à virgule distincts. Ces nombres sont dits représentables (par
le codage en question). Le premier enjeu du choix de la représentation des nombres à virgule est
de fixer l’ensemble des nombres représentables.

Les langages de programmation permettent généralement d’utiliser des nombres non-repré-


sentables dans le code source. À la compilation, ces nombres sont arrondis à un nombre repré-
sentable proche. Ainsi,

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).

1.3.2 Virgule fixe


L’écriture décimale usuelle s’étend naturellement en binaire. Ainsi, comme on écrit π ≈ 3.14
on peut écrire π ≈ (0011.0010 0100)2 . Formellement,

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.

Un nombre x est représentable sur k.` bits si et seulement si 2` x est


un entier de [0, 2k+` [.

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.

Exercice 10 FF Calculer l’approximation par tronquage de 0.1 en 8.8 bits.

1.3.3 Notation scientifique


La notation scientifique de base b représente un nombre x par une paire (e, m) telle que
x = m ∗ be , avec 0 ≤ m ≤ b. Vous êtes déjà familiers avec la notation scientifique de base 10,
couramment utilisée en physique 5 ; les processeurs utilisent la notation scientifique de base 2
pour coder les nombres à virgule. On appelle e l’exposant et m la mantisse. Ici, la virgule est
flottante car e n’est pas constant (contrairement à ce que l’on a vu au paragraphe précédent).

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 :

(1000 0000 0000 0000)2 = 1 ∗ 2(1111)2 → ((1111)2 , (1)2 )


(1000 0100 0010 0001)2 = 1.000 0100 0010 ∗ 2(1111)2 → ((1111)2 , (1.000 0100 0010)2 )

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.

1.4 Norme IEEE 754


Le développement de nombreux formats propriétaires de nombres à virgule, et les problèmes
de non-portabilité associés, ont conduit l’Institute of Electrical and Electronics Engineers à
mettre au point une norme. La première version, de 1985, est suivie d’une révision substantielle
en 2008 et d’une révision plus restreinte en 2019. Nous allons donner ici un aperçu de ce que
spécifie cette norme (en nous basant principalement sur la version de 2008).

5. c ≈ 3 ∗ 108 et NA ≈ 6.022 ∗ 1023 .

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 :

16 bits 32 bits 64 bits 128 bits


S 1 1 1 1
E 5 8 11 15
M 10 23 52 112

Ainsi, le flottant codé par (1101 1000 0010 0000)2 a S = 1, E = 1 0110 et M = 000 0010 0000.

1.4.2 Décodage (cas général)


Soit w un mot binaire de champs S, E et M = (mk mk−1 . . . m0 )2 . Si E vaut (00 . . . 0)2 ou
(11 . . . 1)2 le décodage suit des règles spéciales (cf ci-dessous). Sinon, w encode la valeur

(−1)S 2E−biais (1.mk mk−1 . . . m0 )2

avec

16 bits 32 bits 64 bits 128 bits


biais 15 127 1023 16383

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

Exercice 11 F On s’intéresse ici au test d’égalité sur les flottants.


a. Écrire un programme C qui, pour chaque paire d’entiers (i, j) ci dessous, initia-
lise les trois variables float (en C) à

a = i/10.0, b = j/10.0, c = (i+j)/10.0

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) :

b. Pour comprendre, faire afficher les valeur de a+b et c. (Attention à ce que


l’affichage ne limite pas le nombre de chiffres significatifs.)
c. Peut-on en déduire dans quel(s) cas on peut utiliser l’égalité entre flottants ?
d. Pour chaque paire (i, j) qui échoue lors du test de la première question :
• déterminer lequel de a+b et de c est le plus grand,
• puis ajouter 10−6 à la fois à a+b et à c,
• et vérifier si l’ordre entre a+b et c a changé.
D’après vos observations, que peut-on espérer ?

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

Modèle plat de la mémoire


et chronométrage

Ce chapitre met en place deux éléments fondamentaux, et indépendants, pour la suite du


cours.

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.

2.1 Modèle de mémoire plate


Commençons par décrire de quelle manière un programmeur C interagit avec la mémoire.

2.1.1 La mémoire est un tableau


Du point de vue du programmeur C, et plus généralement d’un processus exécuté sur un
ordinateur classique, la mémoire RAM se comporte comme un grand tableau d’octets. L’indice
d’un octet dans ce tableau est appellé son adresse.

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++ ?

Un programmeur peut avoir l’impression de disposer de l’intégralité de l’espace mémoire de


la machine. C’est une impression trompeuse car cette mémoire est en réalité partagée (par le

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.

Dans ce cours, on ne travaillera qu’en terme d’adresses virtuelles (sauf peut-être


à la toute dernière séance...). Ainsi, la mémoire est un tableau d’octets qui
commence à l’adresse 0.

2.1.2 Allocation mémoire : deux mécanismes


Il est fréquent qu’un programme ait besoin de réserver un espace mémoire un peu conséquent,
par exemple pour y stocker des données sur lesquelles il doit travailler. Le langage C offre deux
manières classiques de faire cela.

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];

Cette commande réalise les opérations suivantes :


• elle réserve un espace mémoire, ici l’espace nécessaire au stockage de 100 int, c’est à dire
de 100*sizeof(int) octets,
• elle crée une variable supplémentaire (ici tab) de type int* qu’elle initialise par l’adresse
de l’espace mémoire réservé.
L’espace mémoire réservé n’est pas initialisé. Autrement dit, immédiatement après que le tableau
ait été déclaré, les variables tab[i] ont une valeur qui dépend du contenu de la mémoire à
l’endroit qui leur a été dévolu au moment où la réservation à été faite. Soulignons quelques
particularités de cette méthode de réservation mémoire :
• La taille de l’espace mémoire alloué (ici 100*sizeof(int)) doit être connu à la compila-
tion : il ne peut donc pas dépendre d’un paramètre passé en ligne de commande ou d’un
calcul effectué au cours du programme.
• L’espace mémoire ainsi alloué sera libéré dès la fin de l’exécution de la fonction au cours
de laquelle la réservation a eu lieu.
• L’espace mémoire est réservé dans une partie de la mémoire allouée au processus que l’on
appelle sa pile. La pile est généralement de taille limitée et ne représente qu’une petite
partie de la mémoire disponible sur la machine.

Exercice 1 F Déterminez expérimentalement :

a. ce qui se passe sur votre machine/système lorsque l’on essaye de réserver de


cette manière un tableau dont la taille excède l’espace disponible sur la pile.
b. l’ordre de grandeur de la taille disponible sur la pile de votre programme.

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.

À partir de cette séance, toute réservation mémoire d’un tableau « un


peu grand » devra se faire sur le tas, c’est à dire via malloc.

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 :

|0xFF | 0xCC | 0x88 | 0x22 |


(big endian)
&a &a+1 &a+2 &a+3
%
0xFFCC8822
&
|0x22 | 0x88 | 0xCC | 0xFF |
(little endian)
&a &a+1 &a+2 &a+3

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;

if ((*p) == 0xFF) printf("big endian\n");


else if ((*p) == 0x22) printf("little endian\n");
else printf("???\n");

Exercice 3 F Déterminez l’endianness utilisée par votre machine/système.

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

for (int i=0; i < MAX; ++i)


a = a*a;

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 :

int dum = 0; int dum = 0;


for (int i=0; i<n; i++) for (int i=0; i<n; i++)
for (int j=0; j<n; j++) for (int j=0; j<n; j++)
dum += tab[i*n+j]; dum += tab[j*n+i];

2.2.2 L’outil : rdtscp


Les processeurs x86 maintiennent un compteur appelé time stamp counter (tsc) qui compte
le nombre de cycles d’horloge depuis la dernière remise à zéro, sous la forme d’un entier non-signé
64 bits. Il est possible de lire la valeur de ce compteur au moyen de la fonction C __rdtscp()
de la bibliothèque x86intrin.h. 1
1. Cette fonction exécute l’instruction assembleur rdtscp qui lit le tsc.

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>

unsigned long int test(int MAX){


unsigned long int tic,toc;
unsigned int ui;

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.

2.2.3 Limitations et biais


Bien que la résolution du tsc soit très fine, les mesures qu’il permet peuvent être approxi-
matives pour plusieurs raisons
• Les conditions d’utilisation du processeur peuvent varier d’une exécution à l’autre, avec
par exemple le réveil de certaines tâches de fond qui viennent occuper le processeur sur
lequel se font les mesures.
• Il est courant que la fréquence du processeur s’adapte à la charge de travail de manière
à réduire la consommation énergétique en l’absence de gros calculs. Les performances du
processeur au fil de l’exécution d’une tâche peuvent donc varier. Pour plus d’information,
regardez du côté des commandes linux cpufreq et cpupower.
• Certains éléments de l’architecture s’adaptent à l’exécution d’un programme et en accé-
lèrent l’exécution au fil des répétitions.
Nous allons donc effectuer nos mesures avec les conventions suivantes :
a. On appellera la fonction à chronométrer plusieurs fois avant de commencer à mesurer. Cet
“amorçage” assure que les mesures ultérieures seront moins affectée par des changements
tels que préchargement du cache ou changement de mode du CPU.
b. Une fois l’amorçage effectué, on chronométrera le temps moyen d’exécution sur de nom-
breuses exécutions.

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 1 000 10 000 106 107 108


mesure

Exercice 5 FF Essayons maintenant de mettre en évidence une source d’erreur


de mesure.
a. Écrivez une fonction acces qui prends en entrée un int* pointant sur un tableau
d’entiers et un entier n, et qui accède en lecture aux n premières cases de ce
tableau.
b. Réservez (sur le tas !) un tableau de N = 109 entiers et pour chacune des valeurs
de n du tableau suivant, appelez votre fonction trois fois de suite en mesurant
à chaque fois le temps pris.

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

1 void test(int a){


2 // ...
3 }

On la transmet en argument à print_timing en utilisant un pointeur de fonction :

1 void print_timing(int mon_arg, void (*ma_fonction)(int)){


2

3 // votre code peut appeler la fonction à mesurer


4 // par ma_fonction(mon_arg)
5 // il doit afficher (printf) le résultat de la mesure
6

7 }

Ainsi, pour le paramètre a = 12 il suffira d’appeler la commande

1 print_timing(12,test);

Si le type de la fonction à mesurer change, il faudra adapter la fonction print_timing


en conséquence, mais la modification sera légère.
a. Écrivez une fonction print_timing selon le modèle ci-dessus et qui prends en
compte les deux sources d’erreurs identifiées en cours (amorçage et moyenne).
Testez cette fonction.
b. Si ce n’est pas déjà fait, ajoutez en paramètre à print_timing le nombre d’ap-
pels souhaités à l’amorçage et le nombre d’appel sur lequel se fait le moyennage.
c. Disposez-vous d’une fonction print_timing complètement opérationelle ?
 Oui  Non
d. Utilisez votre fonction print_timing pour chronométrer la fonction acces écrite
à l’exercice 5.

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 .

Exercice 7 FF On se propose de mesurer l’effet de l’espacement entre accès


mémoires. Écrivez une fonction prenant en argument un entier pas et réalisant N ≈
107 accès dans TAB en espaçant les accès d’un pas fixe : on commence à la case
d’indice 0 et l’accès à la case i est suivi de l’accès à la case i+pas. Dans le cas où
vous devriez sortir du tableau, repartez à partir du début (ie. l’indice est calculé
modulo TAILLE). Mesurez les temps d’exécution de cette fonction pour les valeurs
de pas suivantes.

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.

Exercice 8 FF On va maintenant faire n accès à TAB et comparer les temps pris


quand ces accès sont consécutifs et quand ils sont aléatoires.
a. Écrire deux fonctions qui prennent un entier n en paramètre et accèdent en
écriture à n cases de TAB : acces_seq qui parcoure les cases dans l’ordre (ac-
cès séquentiel) et acces_alea qui parcoure les cases dans un ordre aléatoire.
Mesurer les temps pris par ces deux fonctions :

n= 1000 106 107 108 109


Accès séquentiel
Accès aléatoire

b. Qu’observe-t-on ? Proposez une explication argumentée.


c. On va reproduire cette expérience de manière à ne chronométrer que les accès
mémoire. Pour cela, on prépare un tableau auxiliaire aux de n cases, que l’on
initialise aux indices des cases auxquelles on souhaite accéder. On effectue en-
suite une série de lecture aux cases TAB[aux[0]], TAB[aux[1]], . . . Dans une
première expérience, on remplit aux par des valeurs qui se suivent. Dans une
seconde expérience, on le remplit par des valeurs aléatoires. On ne chronomètre
que les accès en lecture, en excluant les temps de préparation de aux.

n= 1000 106 107 108 109


Accès séquentiel
Accès aléatoire

Qu’observe-t-on ?

26
Chapitre 3

Hiérarchie mémoire

La quantité de mémoire disponible et la puissance de calcul des processeurs augmente ré-


gulièrement, mais la capacité de transfert des données entre mémoire et processeur évolue plus
lentement. Ce point est depuis longtemps identifié comme un des facteurs critiques en architec-
ture des ordinateurs. Les systèmes actuels tentent de limiter cela au moyen de systèmes mémoires
hiérarchiques, l’objet de cette séance.

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 :

débit maximum temps d’accès taille typique


disque dur SSD 25-450 Moct/s 0.1 ms ∼ To
disque dur HDD 25 Moct/s 3-12 ms qq To
RAM 10 Goct/s 10-100 ns ∼ Go
L2 cache 200 Goct/s ∼ 5 ns ∼ Mo
L1 cache 700 Goct/s ∼ 1 ns ∼ 100 Ko
registre - ∼ 0.1 ns ∼ qq centaines d’octets

Ces caractéristiques dépendent naturellement des technologies sous-jacentes. Cependant, il faut


aussi noter que même à technologie fixée, il est en principe possible de garantir de meilleures
performances pour une petite capacité mémoire que pour une grosse capacité. En effet, dans
une petite mémoire, l’information à moins de distance à parcourir (rien n’est instantané) et
l’adressage est plus petit (donc consomme moins de transistors, etc.).

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 :

• Le fonctionnement du cache est transparent du point de vue de l’utilisateur, qui adresse


simplement une requête au système {cache+stockage}. Le fait que la requête soit traitée
par un intermédiaire (le cache) et non le stockage n’affecte que le temps de réponse.
• L’accès à une donnée n’est accélérée que pour les accès en cache, c’est à dire si la donnée
est déjà en cache. Il est vraisemblable que l’ajout d’un intermédiaire ralentisse les accès
hors-cache par rapport à un système sans cache.

3.3 Cache dans un ordinateur


Examinons maintenant les systèmes de cache dans un ordinateur. On se contente pour l’ins-
tant d’un système à deux niveaux : une mémoire rapide (petite) et une mémoire lente (grande).
Soulignons que l’on ne peut accéder à une donnée que par son adresse en mémoire lente. Le pro-
gramme, et le programmeur, n’a en effet qu’une influence indirecte sur le contenu de la mémoire
rapide.

3.3.1 Blocs et ligne de cache


Les systèmes de cache pour les mémoires d’ordinateur ont une spécificité importante : les
transferts entre mémoire lente et mémoire rapide ne se font pas octet par octet, mais par blocs.
Tous les blocs ont la même taille ; ce paramètre, appelé taille de ligne de cache, est déterminé
au niveau électronique par l’architecture. On le notera b.

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.

Ce principe de fonctionnement éclaire l’exercice 7 du chapitre précédent où l’on a chronométré


des accès à un tableau d’int, deux accès consécutifs se faisant à des entrées du tableau espacées
de k cases. Si on note b la taille de ligne de cache et i le nombre d’octets occupé par un int,
chaque accès hors-cache provoque le chargement en cache de bi int consécutifs. On aura donc
b
approximativement ki accès en-cache entre deux accès hors-cache. À mesure que k augmente,
b
la proportion d’accès hors-cache augmente aussi. L’effet disparait dès que ki < 1 : à ce stade,
les accès mémoires sont tellement espacés que deux accès consécutifs ne portent jamais sur des
entiers d’un même bloc. Au final, on retrouve bien les deux phénomènes observés : le temps pris
par les accès augmente quand k augmente, jusqu’à atteindre un palier. Des valeurs numériques
typiques sont de b = 128 et i = 4, ce qui fait plafonner le temps d’accès à partir de de k = 32.

3.3.2 Adresse et numéro de bloc.


La mémoire lente est divisée en blocs, que l’on peut numéroter de 0 à M/b − 1, où M est la
taille mémoire totale et b la taille d’un bloc. Il peut être utile de savoir déterminer le numéro de
bloc de cases mémoires dont on connaît les adresses, par exemple pour comprendre si accéder à
l’une aura ou non pour conséquence de charger l’autre en cache.

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.

Exercice 1 F Dans un système à deux niveaux de mémoire où la taille de ligne


de cache est de 128 octets, les octets situés aux adresses suivantes en mémoire lente
sont-ils dans un même bloc ?

0xAE01D500 et 0xAE01D501 oui  non  0xAE01D53D et 0xAE01D542 oui  non 


0xAE01D500 et 0xAE01D4FF oui  non  0xAD01F506 et 0xAE01D508 oui  non 
0xAE01D500 et 0xAE01D580 oui  non  0xAE01D55F et 0xAE01D560 oui  non 

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) :

p adresse p adresse p adresse


2 0xAE01D500 3 0xAF01D900 11 0xBF01DD00
9 0xAE01D514 17 0xAF01D914 5 0xBF01DD14
19 0xAE01D528 12 0xAF01D928 18 0xBF01DD28
14 0xAE01D53C 20 0xAF01D93C 27 0xBF01DD3C
6 0xAE01D550 1 0xAF01D950 10 0xBF01DD50
15 0xAE01D564 25 0xAF01D964 24 0xBF01DD64
26 0xAE01D578 22 0xAF01D978 4 0xBF01DD78
23 0xAE01D58C 8 0xAF01D98C 21 0xBF01DD8C
16 0xAE01D5A0 7 0xAF01D9A0 13 0xBF01DDA0

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 :

taille = 32Ko, ligne de cache = 64 octets, associativité = complet.

Combien de blocs ce cache peut-il stocker simultanément ?


Donner une cellule (par sa position) qui est stockée à cheval sur deux blocs.
b. Dans le système décrit à la question (a), supposons que l’on traverse notre liste
chaînée de la cellule 1 à la cellule 27, par position croissante (1, puis 2, puis 3,
. . . ), en accédant à chaque fois à la valeur et au pointeur sur le nœud suivant.
Quelles sont les cellules dont la traversée provoque un accès hors-cache ?

3.4 Hiérarchie mémoire


Le système mémoire d’un ordinateur est formé d’une hiérarchie comportant plusieurs niveaux
de cache. La hiérarchie d’une machine typique comporte 4 niveaux : 3 niveaux de mémoire cache
(L1, L2, L3) et la mémoire RAM.

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.

Chaque paire de niveaux consécutifs de la hiérarchie (L1-L2, L2-L3 et L3-RAM) fonctionne


sur le modèle à deux niveaux décrits précédemment. Les transferts entre la mémoire lente et la
mémoire rapide se font par blocs, et la mémoire rapide (plus petite) doit gérer son ensemble de
blocs mémorisés au moyen d’une stratégie de pagination.

Notons que sur les architectures récentes :


- Le niveau L1 comporte en général deux cache indépendants, un pour les instructions et
l’autre pour les données.
- Le cache L1 d’instructions est utilisé par le CPU pour des accès mémoire correspondant à
la lecture du programme à exécuter.
- Les caches L1 et L2 sont internes à chaque cœur du processeur, comme on peut le voir par
exemple sur le diagramme de l’architecture skylake (figure 3.1
3.1).
- Le niveau L3 est partagé entre plusieurs cœurs.

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

Exercice 3 F Determinez les paramètres du système de cache de votre machine


en examinant les informations qui se trouvent dans le répertoire

/sys/devices/system/cpu/cpu0/cache

et renseignez le tableau suivant (laisser la 3ème ligne vide pour l’instant) :

L1 instruction L1 données L2 L3
Taille mémoire
Ligne de cache

Exercice 4 FF Maintenant, comparons expérimentalement le temps mis pour


parcourir un tableau 2D par ligne au temps mis pour le parcourir par colonnes. On
“simule” ce tableau 2D par un tableau 1D déclaré par

1 int *tab = malloc(N*N*sizeof(int));

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 :

int dum = 0; int dum = 0;


for (int i=0; i<N; i++) for (int i=0; i<N; i++)
for (int j=0; j<N; j++) for (int j=0; j<N; j++)
dum += tab[i*N+j]; dum += tab[j*N+i];
return dum; return dum;

Au vu des paramètres de la hiérarchie mémoire de votre machine, à quelles valeurs


de N vous attendez-vous à voir un effet de seuil ?
Vérifiez cela expérimentalement. Si ce n’est pas concluant, cherchez des valeurs seuil
expérimentalement et mesurez les temps pris au voisinage de ces seuils. Ne rem-
plissez le tableau ci-dessous qu’avec des mesures concluantes.

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

N1 = 1 000 000, N2 = 1 048 576, etN1 = 1 300 000,

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.

Exercice 5 F Complétez la description des paramètres du système de cache de votre


machine en ajoutant l’associativité de chaque niveau (vous trouverez cette informa-
tion au même endroit que les informations de taille et ligne de cache). Complétez
avec cela la dernière ligne du tableau de l’exercice 3.

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.

Considérons par exemple un cache de taille 32 Mo (C = 32·220 ), d’associativité 8 pour lequel


les blocs sont de b = 64 octets. Chacun de ses sous-cache peut stocker 8 blocs, soit 8 ∗ 64 = 512
octets. Si on ne se sert que d’un sous-cache, la taille effective a été divisée par 64 000. Ces
paramètres sont réalistes...

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).

Exercice 6 FF Reprenez la question (b) de l’exercice 2 pour une associativité


valant 2.

Exercice 7 FFF On pose R = 10 000 et T = 100 000 000. Réalisez l’expérience


suivante pour N = 1 000 000 puis pour N = 1 048 576(= 220 ), et comparez les
résultats :

• 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 ?

Refaire l’expérience pour N = 1 300 000. Que constatez-vous ?

Pouvez-vous proposer une explication argumentée au moyen de la notion d’associa-


tivité ?

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.

Exercice 8 FFF Cet exercice s’intéresse à un algorithme efficace de transposition


de matrice. Le point important est que l’on va concevoir l’algorithme de sorte à ce
qu’il tire au mieux parti de la hiérarchie mémoire.

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 :

n 1 024 4 096 8 192 16 384 65 536

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.

n 1 024 4 096 8 192 16 384 65 536

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.

n 1 024 4 096 8 192 16 384 65 536

temps

36
Chapitre 4

Notions d’assembleur x86

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.

Travail à faire avant la séance 4 :


• lire les sections 4.1, 4.2 et 4.3 (soit 6 pages),
• faire les exercices 1 à 3 (∼ 15 minutes), et
• entrer les réponses à ces exercices sur le
questionnaire sur la page Arche du cours.

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).

Voici par exemple un code C et l’assembleur produit par gcc à sa compilation :

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.

Par exemple, la traduction en langage machine du code assembleur ci-dessus est :

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.

La syntaxe que nous utiliserons au cours des TP est celle d’Intel.

4.2 Obtention du code assembleur


On se contentera en TP d’étudier l’assembleur produit par gcc à la compilation de code C.
Voici deux méthodes.

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

gcc -S -masm=intel test.c

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.

Par objdump. On peut visualiser le code assembleur et le langage machine constituant un


programme par la commande

objdump -d -M intel nom_de_l_executable_a_examiner

L’option -M 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.

1 int sum (int count){


2 int s = 0;
3 int i;
4 for(i = 0; i < count; i++)
5 s+=i;
6 return s;
7 }
8

9 int main() {
10 int count = 100000000;
11 sum(count);
12 }

4.3 Quelques notions d’assembleurs


Les quelques pages qui suivent font un tour d’horizon succint de quelques notions de base
d’assembleur x86. L’objectif est ici d’être capable d’analyser un petit code. Une liste d’instruction
est disponible à :
https://www.felixcloutier.com/x86/

4.3.1 Les bases


Registres 64 bits. Les registres sont des variables internes au processeur. Il y en a peu
et, techniquement, ce sont des cases mémoire situées dans chaque cœur du processeur. Les
principaux registres 64 bits sont les suivants :
- Les registres de travail rax, rbx, rcx et rdx.
- Les registres d’index rsi, rdi et les registres de pointeur rbp, rsp.

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

Arguments source/destination. La majorité des instructions comporte un ou plusieurs


arguments. Si une instruction doit retourner un “résultat”, il est écrit dans un des arguments.
On distingue ainsi les arguments source, qui ne sont pas modifié, et les arguments destination,
qui peuvent être modifiés. Voici quelques exemples :
mov rax, rbx copie la valeur de rbx dans rax
add rax, rbx ajoute la valeur de rbx à celle de rax
sub rax, rbx soustrait la valeur de rbx à celle de rax
inc rdx incrémente la valeur de rdx

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.

add rax, 12 ajoute 12 à rax


sub rax, 0x12 soustrait 18 à rax

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

mov [rax], rbx copie la valeur de rbx en mémoire à l’adresse rax


add rax, [rbx] ajoute la valeur contenue en mémoire à l’adresse rbx à la valeur de rax

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.

Exercice 2 FF Reprenons le code assembleur que vous avez obtenu à l’exercice 1.


Comment sont stockées les variables (registre, RAM, autre) ?

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.

Drapeaux et sauts conditionnels. Le processeur maintient un registre spécial, flags, conte-


nant différents “bits-drapeaux” : ZF (zero flag), CF (carry flag), OF (overflow flag), SF (sign
flag), PF (parity flag). . . Chaque instruction modifie tout ou partie des drapeaux. Par exemple,

add rax, rbx mets ZF à 1 si à la fin, rax vaut 0, et à 0 sinon


mets CF à 1 si un dépassement de capacité s’est produit en non-signé
mets OF à 1 si un dépassement de capacité s’est produit en signé
...

Les drapeaux sont utilisés indirectement au travers de sauts conditionnels, par exemple :

jz 0x4300 effectue un jmp 0x4300 si ZF=1, ne fait rien si ZF=0

Il existe différentes instructions de saut conditionnel, chacune associée à une condition sur un
ou plusieurs drapeaux.

Exercice 3 FF Reprenons le code assembleur que vous avez obtenu à l’exercice 1.


a. Donnez les instructions assembleur qui correspondent à la boucle for (lignes
4-5), en distinguant l’initialisation, le test et l’incrément.

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 ?

4.3.3 Plus en détail


Quelques instructions de plus. On liste ci-dessous les principales instructions que l’on uti-
lisera. Nous ne détaillons pas ici le fonctionnement de chacune, et renvoyons pour cela aux
ressources listées en début de Section 3. Notons par ailleurs que toutes les mnémoniques n’ad-
mettent pas tous les types d’arguments (registre, ímmédiat, indirect, indirect avec décalage, . . . ).
À nouveau, nous renvoyons aux ressources de référence pour la liste de ce qui est possible.

mov copie de donnée


add, sub, inc, dec opérations arithmétiques
and, or, xor, not opérations logiques (bit à bit)
ror, rol, shr, shl opération de décalage et rotation (bit à bit)
sar, sal
mul, imul, div, idiv multiplication et division
call, ret, jmp saut/retour
jz, jnz, jc, jnc sauts conditionnels, voir https://www.felixcloutier.com/x86/jcc
jg, jng, jge, jnge
...

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.

Autres registres. D’autres registres existent : certains servent au fonctionnement du CPU


(ip, flags, voir plus loin), d’autre servent au FPU, d’autres aux instructions vectorielles, . . .

À 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

Exercice 4 F Indiquer ce que vaut le registre rax lors de l’exécution de l’instruction


nop pour chacun des codes suivants.

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

Exercice 5 F Indiquer ce que vaut le registre rax lors de l’exécution de l’instruction


nop pour chacun des codes suivants.

mov rax,12 mov rax,12


mov rax,12 call fonc call fonc
call fonc nop nop
nop jmp loin jmp loin
jmp loin fonc: push rax fonc: push rbx
fonc: mov rax,15 mov rax,15 mov rax,15
ret pop rax pop rax
ret ret

Exercice 6 F Dans cet exercice, nous allons examiner la traduction en langage


machine du programme suivant :

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 ?

Exercice 8 FF Écrivez un code assembleur x86 réalisant les tâches suivantes.


Dans chaque cas, on prendra soin de ne modifier à terme que les registres qu’il est
demandé de mettre à jour.

a. mettre dans rax le minimum de 4∗rax+5 et de 8∗rbx+7,


b. ajouter rbx à rax tant que le résultat est inférieur à rcx,
c. mettre rcx à 1 si rax est un multiple de 4 et à 0 sinon.

Exercice 9 FFF (Optionnel) Écrivez un code assembleur qui prends en entrée


un entier a, qui calcule la suite de Collatz à partir de a,

un /2 si un est pair
u0 = a et un+1 =
3un + 1 si un est impair

et qui termine quand un vaut 1.

Exercice 10 FFF (Optionnel) Examinons maintenant ce que devient un code


récursif en assembleur.

a. On considère le code suivant :

int fac(int n){ return (n<=1) ? 1 : n * fac(n-1); }

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 :

int fib2_rec(int n, int acc1, int acc2){


return (n<=2) ? acc2 : fib2_rec(n-1, acc2, acc1 + acc2);
}

int fib2(int n){ return fib2_rec(n,1,1); }

e. Quelle est la différence entre les fonctions fib et fib2 du point de vue de la récursivité ?

45
46
Chapitre 5

Pipeline et prédiction de branchement

Cette 5ème séance introduit à la notion de pipeline dans les processeurs et le problème de la
prédiction de branchement.

5.1 Problématique : un peu d’algorithmique


Supposons que l’on souhaite calculer le minimum et le maximum d’un tableau de taille n.
Sans hypothèse particulière sur le tableau, il est naturel de le parcourir séquentiellement en
maintenant les petits et plus grands éléments vus. Comparons deux approches pour cette mise
à jour :

1 void b(int* tab, int* l, int* u)


2 {
3 register int i,lo,up,v1,v2;
4 lo = tab[0];
up = tab[0];
1 void a(int* tab, int* l, int* u) 5

6 for(i=1; i<taille; i+=2)


2 { 7 {
3 register int i,lo,up,v; 8 v1=tab[i];
4 lo = tab[0]; 9 v2=tab[i+1];
5 up = tab[0]; 10 if (v1 < v2)
6 for(i=1; i<taille; i++) 11 {
7 { 12 if (v1 < lo)
8 v=tab[i]; 13 lo=v1;
if (v2 > up)
if (v<lo)
14
9
15 up=v2;
10 lo=v; 16 }
11 if (v>up) 17 else
12 up=v; 18 {
13 } 19 if (v2 < lo)
14 *l = lo; 20 lo=v2;
15 *u = up; 21 if (v1 > up)
up=v1;
16 return; 22

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.

5.2 Principe d’un pipeline


Une nanoseconde dans la vie d’un CPU. Une fois compilé, notre programme prends la
forme d’une séquence d’octets en mémoire, à interpréter comme du langage machine. Lors de
l’exécution, le registre ip contient l’adresse du premier octet de l’instruction courante. Supposons
que la zone mémoire pointée par ip contienne

01 c2 83 c0 01 3d 41 42 0f 00 . . .

Le traitement de l’instruction suivante nécessite plusieurs opérations distinctes :


• Il faut décoder l’instruction. Ici, 01 c2 code add edx, eax et correspond donc à la pro-
chaine instruction à exécuter.
• Il faut lire les opérandes. Ici, il faut transmettre en entrée du circuit additionneur les
valeurs vd et va des registres edx et eax.
• Il faut exécuter l’instruction. Ici, il s’agit de calculer, via un circuit additionneur la valeur
vd0 = vd + va mod 232 ainsi que les retenues signée et non signée.
• Il faut écrire les résultats. Ici, il faut mettre à jour le registre edx avec le résultat vd0 et
positionner les flags modifiés par l’instruction add.
Une fois cela fait, on peut ajouter 2 à ip pour le faire pointer sur l’instruction suivante. La zone
mémoire pointée par ip contient donc

83 c0 01 3d 41 42 0f 00 . . .

et on peut recommencer : décodage, lecture d’opérandes, exécution, écriture . . .

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.

Parallelisme et dépendance. Un pipeline à k niveaux peut, en régime permanent, traiter k


instructions à la fois. C’est une forme de parallélisme. Cette perspective optimiste est à tempérer
car il est possible que des instructions qui se suivent soient dépendantes. Par exemple, dans

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.

5.3 Exemple de pipeline à 5 niveaux


Un modèle classique de pipeline simple (qui a existé dans le processeur 80 486) comporte 5
niveaux :
• Instruction prefetch charge les instructions suivantes à exécuter dans un tampon
interne au processeur. Sur une architecture ayant une ligne de cache `, il est naturel que
le tampon soit de taille 2` et que le prefetch se fasse par tranche de ` octets.

• 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]).

• Execution réalise effectivement l’exécution de l’instruction : addition, opération logique,


lecture en mémoire,

• 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 :

1 mov eax, [rbp-4] 1 mov eax, [rbp-4]


2 imul eax, 6 2 mov ebx, [rbp-8]
3 mov [rbp-4], eax 3 imul eax, 6

4 mov ebx, [rbp-8] 4 add ebx, 2
5 add ebx, 2 5 mov [rbp-4], eax
6 mov [rbp-8], ebx 6 mov [rbp-8], ebx

5.4 Gestion des sauts conditionnels


Examinons maintenant le traitement des sauts conditionnels dans des pipelines généraux.

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).

Prédicteur élémentaire. Les spécifications effectives des prédicteurs de branchement sont


peu documentées. Pour illustrer le type de méthodes mises en œuvre, décrivons le principe des
compteurs saturants. Il s’agit d’associer à chaque instruction de saut conditionnel un compteur
qui mémorise l’historique récent des branchements :
• À chaque fois que saut est pris, le compteur est incrémenté mais avec saturation, c’est à
dire que s’il atteint sa valeur maximale, il garde cette valeur. Par exemple, incrémenter un
compteur saturant sur 2 bits valant (11)2 , le laisse à (11)2 .
• À chaque fois que saut n’est pas pris, le compteur est décrémenté avec saturation : si le
compteur valait zéro, la décrémentation le laisse à zéro.
Lorsque le processeur atteint à nouveau cette instruction de saut conditionnel, il peut utiliser
le bit de poids fort de ce compteur comme un prédicteur du comportement : s’il est à 1 il parie
que le saut sera pris, s’il est à 0 il parie que le saut ne sera pas pris.

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

Exercice 1 FF On s’intéresse dans cet exercice au comportement d’un processeur


dont le pipeline utilise un prédicteur de branchement à 4 états.

saut pris saut pris saut pris

Prédit : Prédit : Prédit : Prédit : saut


saut
oui oui non non pas pris
pris
1 2 3 4
saut saut saut
pas pris pas pris pas pris

a. On considère le programme suivant et sa traduction en assembleur :


1 compte: mov rdx, rsi
1 int compte(int n, int* tab){ 2 lea ecx, [rdi-1]
2 int s; 3 lea rsi, [rsi+4+rcx*4]
3 4 .L1: cmp DWORD PTR [rdx], 50
4 for (int i = 0; i < n; ++i) 5 jge .L2
5 if (tab[i]<50) 6 inc eax
6 s += 1; 7 .L2: add rdx, 4
7 return s; 8 cmp rdx, rsi
8 } 9 jne .L1
10 ret
Indiquez les numéros de ligne des instructions assembleur de saut conditionnel et, pour
chacune d’entre elle, le numéro de ligne de l’instruction C correspondante.
b. On suppose que le processeur consacre un prédicteur de branchement 4 états uniquement
à l’instruction assembleur de la ligne 5 (jge .L2). On appelle la fonction sur un tableau
de 20 cases dont les valeurs provoquent le résultats suivants :

indice dans tab 0 1 2 3 4 5 6 7 8 9


saut pris ? oui oui oui non non non non non oui oui

indice dans tab 10 11 12 13 14 15 16 17 18 19


saut pris ? oui non oui non oui non non oui non oui

(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.

Expérience. Initialisons un tableau de N≈ 107 entiers


aléatoires choisis uniformément entre 0 et 99. Écrivons
une fonction C qui compte les nombres inférieurs à un
seuil, donné en paramètre, dans un tableau d’entiers.
Mesurons le temps pris par notre fonction sur ce tableau
lorsque le seuil varie de 0 à 100.

Observation. On devrait constater que le temps pris


par la boucle augmente (linéairement) quand le seuil
varie de 0 à 50, puis dimininue quand le seuil varie de
50 à 100.

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 ?

Exercice 3 FF On s’intéresse maintenant aux sauts conditionnels imbriqués.

a. Expliquez comment on peut réaliser, en assembleur, un test composite de la forme

if ((tab[i]>24) && (tab[i]<51)) ...

Réalisons maintenant l’expérience suivante :

Expérience. Initialisons un tableau de N≈ 107 entiers


aléatoires choisis uniformément entre 1 et 100. Écrivons
deux fonctions C qui comptent les entrées de ce tableau
dans l’intervalle [25, 50] au moyen de tests de la forme :

if (v>24) if (v<51)
if (v<51) et if (v>24)
j++; j++;

Mesurons le temps pris par ces fonctions.

52
Observation. L’une devrait être sensiblement plus ra-
pide que l’autre.

b. Réalisez cette expérience (en compilant en -O0 -falign-functions). Votre expérience


est-elle concluante ?

 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 ?

Exercice 4 FF Dans cet exercice, on s’intéresse à la manière dont le compilateur


gcc optimise le code pour réduire les bulles dans le pipeline. On prendra comme
exemple le code C suivant :

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.

6.1 Quelques notions de sécurité


Précisons tout d’abord quelques notions de sécurité. Il ne s’agit pas de faire un cours d’in-
troduction à cette thématique mais simplement de définir quelques notions dont nous auront
besoin.

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.

6.2 Principes de Spectre et Meltdown


« Spectre » et « Meltdown » sont les noms de deux failles de sécurité qui permettent essen-
tiellement à un programme de lire des zones mémoires auxquelles il ne devrait pas avoir accès.

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.

6.2.1 Idée 1 : l’impunité de la spéculation


La première idée est qu’il existe une situation dans laquelle un accès mémoire interdit
reste impuni : lorsque cet accès est exécuté dans le cadre d’une exécution spéculative mal
prédite. Examinons par exemple le code suivant (et une traduction possible en assembleur) :

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.

6.2.2 Idée 2 : la spéculation erronée laisse des traces


L’exécution spéculative est un mécanisme d’accélération qui doit s’avérer transparent. Ainsi,
ses spécifications indiquent qu’une spéculation erronée ne doit laisser aucune trace dans le pro-
cesseur : les registres, drapeaux, etc. doivent être remis dans l’état qu’ils auraient eu si l’exécution
spéculative erronée n’avait pas eu lieu.

Ce spécifications ne couvrent cependant pas le reste de la micro-architecture en général,


et la hiérarchie mémoire en particulier. Il s’avère que si un accès mémoire exécuté lors d’une
exécution spéculative erronée a un effet (chargement/déchargement) sur un niveau de cache,

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.

6.2.3 On combine le tout


Spectre et Meltdown combinent ces deux idées de la manière suivante :
• On crée un tableau mémoire T auquel on a le droit d’accéder et qui contient 256 blocs
distincts, que l’on numérote de 0 à 255.
• On s’assure qu’aucun de ces blocs ne se trouve en cache, dans aucun niveau de cache.
• On déclenche l’exécution spéculative erronée d’un code qui (1) lit l’octet cible x auquel on
n’a pas le droit d’accéder, et (2) fait un accès au bloc No x du tableau T . Cette exécution
spéculative provoque donc le chargement de ce bloc de T en cache, chargement qui persiste
alors même que l’exécution spéculative est annulée.
• On chronomètre le temps mis pour accéder individuellement à chacun des blocs de T . L’un
d’entre eux, disons le No i, sera sensiblement plus rapide à charger. On peut en déduire
que x valait i.
• On connaît maintenant le contenu de la case mémoire à laquelle il nous était interdit
d’accéder en lecture.

6.3 Dans le détail : des soucis et des outils


La mise en œuvre de ces idées pose un certain nombre de difficulté techniques dont aucune
n’est insurmontable. Passons-en quelques unes en revue.

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);

La commande mlock(adr,taille) verrouille en RAM taille octets à partir de l’adresse adr.


La commande munlock(adr,taille) les déverrouille ; cela ne les expulse pas de RAM, mais a
simplement pour effet de rendre possible leur évacuation. Pour notre usage, on va verrouiller
puis immédiatement déverrouiller chaque octet utile.

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.

Empêcher l’embalement spéculatif. L’exécution spéculative d’un processeur actuel peut


anticiper des centaines d’instructions. Ainsi, il n’est pas évident que l’étape de vidage de cache
soit effectivement complètement résolue avant que l’accès « spéculatif » ne commence. On peut
résoudre cela au moyen de l’instruction assembleur mfence. Cette instruction a pour effet d’ériger
une barrière pour l’exécution spéculative, en s’assurant que chacun des accès mémoires qui la
précède est effectivement réalisé avant que ne commence chacune des instructions qui la suit.
Cette instruction est accessible en C de la même manière que rdtscp et clflush :

#include <x86intrin.h>
...
_mm_mfence();

Chronométrer finement. L’étape de chronométrage demande de réussir à mesurer suffisam-


ment précisément le temps d’un accès mémoire pour discerner s’il a occasionné un accès en- ou
hors-cache. Comme d’habitude, il convient de répéter l’opération de multiples fois et d’utiliser
la moyenne des temps d’accès. Bien évidemment, il faut ici répéter l’intégralité de l’opération :
vidage du cache, exécution spéculative erronée et chronométrage des accès.

É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 :

for (i=0; i<256; i++)


j = ((i * 167) + 13) & 0xff;
tester le bloc j

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.

Exercice 1 FFF Commençons par mettre en place notre système de chronomé-


trage. L’objectif est d’aboutir à quelque chose ressemblant à ceci :

On va procéder par étapes.

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...

6.5 Pour aller plus loin


Pour une source très complète sur ces attaques et certaines de leurs ramifications, voir :
• https://meltdownattack.com/

Pour une preuve de concept de Spectre en javascript, voir :


• https://leaky.page/

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

Pour un article de vulgarisation qui dresse un panorama de conséquences de Spectre et Meltdown


sur l’industrie informatique, voir :
• How to Live in a Post-Meltdown and -Spectre World. Bennett et al. Communication
of the ACM, décembre 2018.
https://cacm.acm.org/magazines/2018/12/232898-how-to-live-in-a-post-meltdown-and-spectre-world/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.

7.1 Des parallelismes


Commençons par préciser le type de parallèlisme opéré par le calcul vectoriel.

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.

Cette évolution s’est prolongée au travers du développement du multithreading et de multi-


cœurs.

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.

7.2 Vectorisation d’algorithme : un exemple


Illustrons l’idée de la vectorisation sur un premier exemple. Supposons que l’on souhaite
modifier une chaîne de caractères en ajoutant 1 au code ASCII de chaque caractère (modulo
256). 1

Solution “naturelle”. Voici une première solution et le code produit par gcc -O2 :

1 incr: jmp .L7


void incr(char* t)
.L5: add eax, 1
1
2
{
add rdi, 1
2
3
while ((*t) != '\0'){
mov BYTE PTR [rdi-1], al
3
4
(*t) += 1;
.L7: movzx eax, BYTE PTR [rdi]
4
5
++t;
test al, al
5
6
}
jne .L5
6
7
}
rep ret
7
8

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 :

1 void pincr(char* t,int n) 1 pincr: test esi, esi


2 { 2 jle .L9
3 unsigned long int* p; 3 lea eax, [rsi-1]
4 p= (unsigned long int*) t; 4 lea rdx, [rdi+8+rax*8]
5 5 movabs rax, 72340172838076673
6 for (int i=0; i<n; ++i){ 6 .L11: add QWORD PTR [rdi], rax
7 (*p)+=0x0101010101010101; 7 add rdi, 8
8 ++p; 8 cmp rdi, rdx
9 } 9 jne .L11
10 } 10 .L9: rep ret

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.

7.3 Vectorisation de boucles par gcc


Pour qu’il soit intéressant de vectoriser un code, il faut que le gain apporté par la vectorisation
compense le coût des changements de registres occasionnés par les aller-retours entre scalaire et
vectoriel. Cela correspond donc à des traitements coûteux, les boucles répétées suffisament de
fois étant un exemple typique.

Difficultés. Voici quelques sources de problèmes dans la vectorisation de boucles :


- Un flot non séquentiel (branchements if, points d’entrée ou de sortie multiples, . . . ) qui
pourrait se comporter différemment pour les différentes composantes d’un même vecteur.
- Un nombre d’itérations non prévisible au moment d’entrer dans la boucle (par exemple la
fonction incr ci-dessus), et qui rends délicate la gestion des itération restant à effectuer
en scalaire.
- L’appel de fonctions, qui même inliné peuvent cacher un code à flot compliqué. Notons,
cependant, que certaines fonctions courantes disposent de versions vectorielles (comme pow
ou cos par exemple).
- Les accès en mémoire à des adresses non contigües (par exemple accéder à tab[index[i]])
poseront des problèmes d’efficacité de lecture/écriture en mémoire.
- Les dépendances de données provoquant des temps d’attente. Ainsi, sur une même variable,
une lecture/écriture suivant une écriture posera problème, tandis qu’une lecture suivant
une lecture ne posera jamais de problème, et une écriture suivant une lecture ne posera
que parfois des problèmes.
Ajoutons qu’en cas de boucles imbriquées, il peut être difficile de vectoriser plus d’une boucle.

Préconisations. L’option -O3 de gcc tente de vectoriser le code. Le sujet de TP illustre à


quel point gcc est sensible à la rédaction du code lorsqu’il tente de vectoriser une boucle. Au vu
des critères ci-dessus, on ne peut que conseiller de préférer des boucles simples (type for avec
incrément régulier), de limiter les dépendances entre itérations de la boucle, d’utiliser le compteur
de boucle aussi souvent que possible comme indice de tableau, d’utiliser autant que possible un
espacement régulier des données en mémoire et d’éviter autant que possible l’adressage indirect.

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 :

1 void sincr(char* t, int n)


2 {
3 for (int i=0; i<n; ++i){
4 (*t) += 1;
5 ++t;
6 }

7.4 Instructions vectorielles en assembleur


Depuis 1999, les processeurs intel comportent des registres et instructions vectoriels.
registres vectoriels. Les processeurs intel disposent de re-
zmm.
gistres vectoriel qui peuvent se fractionner en blocs, de sorte

{
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.

Par exemple, l’instruction pcmpgtp compare un vecteur source à un vecteur destination,


composante par composante. Chaque composante de la destination est remplacée par −1 ou par
2. https://devblogs.nvidia.com/cuda-9-features-revealed/

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 )

• copier un vecteur dans un autre (ex : w = v )

• charger un vecteur depuis 4 cases consécutives de la mémoire à une adresse donnée en


précisant la taille des cases (ex : w = (char) TAB[i:i+3] indique que l’on charge les
composantes de w par un octet chacune, lus à partir depuis l’adresse TAB[i]).

• charger un vecteur dans 4 cases consécutives de la mémoire à une adresse donnée en


précisant la taille des cases (ex : TAB[0:3] = (char) w indique que l’on place les 4
composantes de w dans 4 cases mémoire consécutives de la taille d’un char à l’adresse TAB)

• 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)

Exercice 1 FF On souhaite écrire un pseudocode qui vectorise le code suivant :

1 int sum (int n){


2 int s = 0;
3 for(int i = 0; i < n; i++)
4 s+=i;
5 return s;
6 }

a. Supposons que n est un multiple de 4. Écrire l’algorithme vectorisé correspon-


dant. Votre algorithme ne doit exécuter l’instruction sum_comp qu’un nombre
constant de fois.
b. Comment fait-on si n n’est pas un multiple de 4 ?
c. La Figure 7.1 présente un exemple de code assembleur produit en compilant avec
gcc -O3 (certaines portions non-essentielles ont été abrégées pour une meilleure
lisibilité). Retrouvez dans ce code les étapes de l’algorithme vectorisé que vous
avez proposé aux questions précédentes.
Note : un descriptif complet des instructions x86 (instructions vectorielles com-
prises) est donné à https://www.felixcloutier.com/x86/

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

Figure 7.1 – Code assembleur obtenu en -O3 pour l’exercice 1.

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.

for(i = 0; i < n-1; i++) Vectorisable ?  Oui  Non


A[i]=B[i+1]; Vectorisée par gcc ?  Oui  Non

for(i = 1; i < n-1; i++){


A[i]=B[i]; Vectorisable ?  Oui  Non
A[i+1]=B[i+1]; Vectorisée par gcc ?  Oui  Non
}

for(i = 1; i < n-2; i+=2){


A[i]=B[i]; Vectorisable ?  Oui  Non
A[i+2]=B[i+2]; Vectorisée par gcc ?  Oui  Non
}

for(i = 1; i < n-1; i++){


A[i]=B[i+1]; Vectorisable ?  Oui  Non
B[i]=C[i]; Vectorisée par gcc ?  Oui  Non
}

Proposez une analyse de ces résultats.

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.

Le code a-t-il été vectorisé ?  Oui  Non

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.

Exercice 4 FF On s’intéresse dans cet exercice à la manière dont la vectorisation


et la hiérarchie mémoire influencent le choix d’une méthode de conversion d’une
image couleur en niveaux de gris.

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 :

char* image = malloc(LARGEUR*HAUTEUR*3);

On envisage deux solutions pour organiser les informations dans le tableau :


. La solution A écrit les trois composantes de chaque pixel à la suite. Les compo-
santes du ième pixel sont donc (image[3*i],image[3*i+1],image[3*i+2]).
. La solution B divise image en trois sous-tableaux

char* rouge=image, vert=rouge+LARGEUR*HAUTEUR, bleu=vert+LARGEUR*HAUTEUR;

Les composantes du ième pixel sont donc (rouge[i],vert[i],bleu[i]), c’est à


dire (image[i],image[i+LARGEUR*HAUTEUR],image[i+2*LARGEUR*HAUTEUR]).

Méthode de conversion. Le code de conversion en niveaux de gris est :

char* dest = malloc(LARGEUR*HAUTEUR*3);


int i,moy;
char gris;
// U et V sont deux constantes définies ci-dessous

for (i=0; i<LARGEUR*HAUTEUR; i++){


moy = (int)image[V*i] + (int)image[V*i+U] + (int)image[V*i+2*U]; // cast en int
moy = moy/3; // pour éviter le % 256
gris = (char)moy; // du calcul en char
dest[V*i] = gris;
dest[V*i+U]= gris;
dest[V*i+2*U] = gris;
}

Les constantes U et V sont définies ainsi :


3. Un pixel est un point d’une image. Une image en résolution 2000×1000 est composée de 2 millions de pixels.

70
Solution A Solution B

#define U 1 #define U LARGEUR*HAUTEUR


#define V 3 #define V 1

Maintenant, c’est à vous de jouer.


a. On exécute la conversion sur un système doté de deux niveaux de mémoire, la
RAM et une mémoire cache de paramètres :

taille = 64 Ko, lignes = 64 octets, associativité = 1.


(a) On suppose que LARGEUR = 1600 et HAUTEUR = 1000. Combien de cache-miss
est-ce que la conversion en niveau de gris déclenche (expliquer le calcul) ?
(b) On suppose que LARGEUR = 1024 et HAUTEUR = 1024. Combien de cache-miss
est-ce que la conversion en niveau de gris déclenche (expliquer le calcul) ?
b. On modifie un seul paramètre de la mémoire cache : on suppose que l’associati-
vité vaut 8, chaque sous-cache utilisant la stratégie de remplacement LRU (least
recently used ). On suppose que LARGEUR = 1024 et HAUTEUR = 1024. Combien
de cache-miss est-ce que la conversion en niveau de gris déclenche (expliquer le
calcul) ?
c. Pour les 2 solutions (A et B), proposez soit une version vectorisée en pseudo-
code de la fonction de conversion en niveaux de gris, soit une explication de ce
qui rend cela difficile.

71

Vous aimerez peut-être aussi