HAL Id: tel-01094327
https://hal.archives-ouvertes.fr/tel-01094327
Submitted on 15 Dec 2014
HAL is a multi-disciplinary open access
archive for the deposit and dissemination of scientific
research documents, whether they are published
or not. The documents may come from
teaching and research institutions in France or
abroad, or from public or private research centers.
L’archive ouverte pluridisciplinaire HAL, est
destin´ee au d´epˆot et `a la diffusion de documents
scientifiques de niveau recherche, publi´es ou non,
´emanant des ´etablissements d’enseignement et de
recherche fran¸cais ou ´etrangers, des laboratoires
publics ou priv´es.UNIVERSITÉ D’ORLÉANS
ÉCOLE DOCTORALE MIPTIS
MATHÉMATIQUES, INFORMATIQUE, PHYSIQUE THÉORIQUE
ET INGÉNIEURIE DES SYSTÈMES
Laboratoire d’Informatique Fondamentale d’Orléans
THÈSE
présentée par :
Hélène COULLON
soutenue le : 29 septembre 2014
pour obtenir le grade de : Docteur de l’université d’Orléans
Discipline/ Spécialité : Informatique
Modélisation et implémentation de parallélisme
implicite pour les simulations scientifiques basées sur
des maillages
THÈSE dirigée par :
Sébastien LIMET Professeur des Universités, Université d’Orléans
RAPPORTEURS :
David HILL Professeur des Universités, Université Blaise Pascal
Christian PEREZ Directeur de Recherche INRIA, ENS Lyon
JURY :
Rob BISSELING Professeur, Université de Utrecht, Pays-Bas
Jose-Maria FULLANA Professeur, Université Pierre et Marie Curie
Frédéric LOULERGUE Professeur, Université d’Orléans (Président)
Daniel PIERRE Directeur pôle scientifique et technique de
Antea France (Convention CIFRE)À mes parents
“Quand je serai grande, je voudrais inventer des choses”Remerciements
Trois ans, une durée qui parait si longue et qui est pourtant si courte ! Voici déjà le
moment de remercier toutes les personnes qui ont contribué directement ou indirectement
à ce travail de thèse, par des financements, des collaborations, ou tout simplement par
l’amitié, le soutien et plus encore.
La thèse représente beaucoup de travail, d’implication, voir même d’acharnement,
mais on oublie souvent de dire que la thèse c’est aussi une très grande part de chance,
comme souvent dans la vie, et certains en ont plus que d’autres. J’ai eu la grande chance
de rencontrer Sébastien Limet, mon directeur de thèse, et de travailler avec lui avant
et pendant (et j’espère après) la thèse. Sébastien a su me diriger et m’aiguiller dans les
bonnes directions, tout en me laissant toujours l’impression de diriger moi même mes
recherches ce qui est un travail très subtil et très difficile à réaliser. Bien évidemment, ma
thèse n’aurait pas été possible sans la société Géo-Hyd (Antea Groupe) qui l’a financée
en convention CIFRE, et sans l’Agence Nationale de la Recherche et de la Technologie
(ANRT) qui a subventionné Géo-Hyd pour cette thèse. J’ai eu, encore une fois, la chance
d’avoir de bonnes conditions de recherche. Je remercie plus particulièrement Daniel Pierre
pour avoir suivi ma thèse en entreprise et pour avoir su protéger mon temps de recherche.
Je remercie également l’ensemble des salariés de Géo-Hyd pour leur sympathie, leurs
encouragements ou leur amitié, et plus particulièrement mes amies Myriam et Leïla.
J’ai passé 5 superbes années au LIFO, d’abord comme ingénieur recherche, puis
comme doctorante. Je tiens donc à remercier le LIFO dans sa globalité et sans exception,
les anciens comme les nouveaux. Je tiens particulièrement à remercier les personnes qui
m’ont fait confiance pour donner des cours au département informatique de l’Université
d’Orléans : Ali, Alexandre, Sophie et Sylvain. Enfin, on les oublie souvent, mais un très
grand merci aux secrétaires Isabelle et Florence, pour leur bonne humeur et leur travail
irréprochable. Merci à mon ancien co-bureau Julien pour ses bons conseils, et aussi merci
à Mon SIM, Monsieur Patate, Iko, Matthieu Lopette, Nicducasquette, Peranth, Florent,
Le Foulque, Le Legaux, Davide, Shiruba, Le Trôme, El Cennalgo, Guigui etc. et tous les
doctorants, ATERs et post-docs passés au LIFO que je pourrais oublier de citer.
Toujours dans le contexte du travail, ma thèse a eu la chance d’être enrichie par un
grand nombre de collaborations. Un grand merci à Minh, Olivier, Stéphane, Christian,
Pierre-Yves, Jose-Maria et Xiaofei pour avoir travaillé avec moi sur des applications de
mon travail. Et un grand merci à Rob de m’avoir reçu à l’Université d’Utrecht et d’avoir
collaboré avec moi sur des problématiques que je ne connaissais pas. Merci aux personnes
qui m’ont aidé à rédiger et relire cette thèse, Sylvain, Pierre-Yves et surtout Caro ! Merci
và mes rapporteurs Christian et David, ainsi qu’aux membres de mon jury Frédéric, Rob,
Daniel pour leur temps et leur expertise.
Apprendre à se remettre en question, à jeter ce sur quoi on travaille depuis des mois
pour partir vers autre chose, apprendre à ne pas trouver de voie, à rester bloqué, autant
de difficultés qui rendent la thèse si difficile et une réelle formation à la recherche. Il
n’est pas rare de déprimer, de vouloir abandonner, de ne pas se sentir à la hauteur,
et alors une thèse parait impossible sans soutien, sans amitié. Merci à mes parents et
ma grande sœur qui m’ont soutenue, comme toujours, dans cette démarche. Merci à mes
amis Simon et Yannick pour les discussions, les pauses, les amusements et les sorties, mais
aussi pour leur aide dans les moments difficiles. Merci au Club de Floorball Orléanais,
aux Atlantics et aux filles de l’équipe de France, plus particulièrement à Caro, Auréa,
Guigui, Juju, Malabar, Élodie, Joss, Pauline etc. Merci au CLTO Hockey sur gazon, plus
particulièrement à Pablo, petit Pierre, Neness et Raymond.
Pour terminer ces remerciements, je remercie ma moitié, Jean-Hugues. Merci de
m’avoir épaulé et supporté pendant ces trois années stressantes et éprouvantes. Merci
de me donner la chance de continuer en recherche en faisant le sacrifice de quitter Orléans
pour Lyon.
viTable des matières
Table des matières vii
Liste des figures ix
1 Introduction 1
1.1 Contexte de la recherche . . . . . . . . . . . . . . . . . . . . . . . . 2
1.2 Contributions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
1.3 Organisation du manuscrit . . . . . . . . . . . . . . . . . . . . . . . . 5
2 Etat de l’art 7
2.1 Calcul parallèle : architectures et programmation . . . . . . . 8
2.1.1 Architectures parallèles . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.1.2 Paradigmes et modèles de parallélisation . . . . . . . . . . . . . . . . . 13
2.2 Problèmes numériques et Simulations scientifiques . . . . . . . . 19
2.2.1 Équations aux dérivées partielles . . . . . . . . . . . . . . . . . . . . . 19
2.2.2 Passage du continu au discret . . . . . . . . . . . . . . . . . . . . . . 20
2.2.3 Méthodes numériques basées sur les maillages . . . . . . . . . . . . . . 23
2.2.4 Exemples de méthodes numériques basées sur des maillages . . . . . . . 25
2.2.5 Programmation et parallélisation . . . . . . . . . . . . . . . . . . . . . 31
2.3 Distribution de données . . . . . . . . . . . . . . . . . . . . . . . . . . 34
2.3.1 Modèles de partitionnement . . . . . . . . . . . . . . . . . . . . . . . 35
2.3.2 Cas particulier du partitionnement de matrices . . . . . . . . . . . . . 38
2.3.3 Cas particulier du partitionnement de maillages . . . . . . . . . . . . . 40
2.3.4 Partitionnements particuliers . . . . . . . . . . . . . . . . . . . . . . . 43
2.4 Le parallélisme implicite . . . . . . . . . . . . . . . . . . . . . . . . . 44
2.4.1 Classification de problèmes et aide à la parallélisation . . . . . . . . . . 45
2.4.2 Solutions partiellement implicites . . . . . . . . . . . . . . . . . . . . 46
2.4.3 Solutions générales de parallélisme implicite . . . . . . . . . . . . . . . 47
2.4.4 Solutions à patrons . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
2.4.5 Solutions spécifiques à un domaine . . . . . . . . . . . . . . . . . . . . 53
2.5 Calculs de performances et difficulté de programmation . . . 56
2.5.1 Mesures de performances . . . . . . . . . . . . . . . . . . . . . . . . . 56
2.5.2 Effort de programmation . . . . . . . . . . . . . . . . . . . . . . . . . 59
2.6 Conclusion et positionnement du travail . . . . . . . . . . . . . . 61
vii3 SIPSim : Structured Implicit Parallelism for scientific Simulations
63
3.1 Structure de données distribuée . . . . . . . . . . . . . . . . . . . . 66
3.2 Application de données . . . . . . . . . . . . . . . . . . . . . . . . . . 67
3.3 Applicateurs et opérations . . . . . . . . . . . . . . . . . . . . . . . 68
3.4 Interfaces de programmation . . . . . . . . . . . . . . . . . . . . . . 68
3.5 Vue utilisateur et vue réelle . . . . . . . . . . . . . . . . . . . . . . 69
3.6 Type de programmation . . . . . . . . . . . . . . . . . . . . . . . . . . 70
3.7 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
4 SkelGIS pour des maillages réguliers à deux dimensions 73
4.1 SIPSim pour les maillages réguliers à deux dimensions . . . . . 74
4.1.1 Structure de données distribuée . . . . . . . . . . . . . . . . . . . . . 74
4.1.2 Applicateurs et opérations . . . . . . . . . . . . . . . . . . . . . . . . 77
4.1.3 Interfaces de programmation . . . . . . . . . . . . . . . . . . . . . . . 78
4.1.4 Spécialisation partielle de template . . . . . . . . . . . . . . . . . . . 80
4.2 Résolution numérique de l’équation de la chaleur . . . . . . . . 82
4.2.1 Équation et résolution numérique . . . . . . . . . . . . . . . . . . . . 82
4.2.2 Parallélisation avec SkelGIS . . . . . . . . . . . . . . . . . . . . . . . 83
4.2.3 Résultats . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85
4.3 Résolution numérique des équations de Saint Venant . . . . . . 89
4.3.1 Équations de Saint Venant . . . . . . . . . . . . . . . . . . . . . . . . 89
4.3.2 Résolution numérique et programmation . . . . . . . . . . . . . . . . . 90
4.3.3 Parallélisation avec SkelGIS . . . . . . . . . . . . . . . . . . . . . . . 92
4.3.4 Résultats . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 94
4.4 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99
5 SkelGIS pour des simulations sur réseaux 101
5.1 Les réseaux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102
5.2 SIPSim pour les réseaux . . . . . . . . . . . . . . . . . . . . . . . . . 106
5.2.1 Structure de données distribuée . . . . . . . . . . . . . . . . . . . . . 106
5.2.2 Application de données . . . . . . . . . . . . . . . . . . . . . . . . . . 107
5.2.3 Applicateurs et opérations . . . . . . . . . . . . . . . . . . . . . . . . 107
5.2.4 Interfaces de programmation . . . . . . . . . . . . . . . . . . . . . . . 108
5.2.5 Spécialisation partielle de template . . . . . . . . . . . . . . . . . . . 111
5.3 Structure de données distribuée pour les réseaux . . . . . . . . 112
5.3.1 Le format Compressed Sparse Row . . . . . . . . . . . . . . . . . . . . 113
5.3.2 Format pour les DAG distribués . . . . . . . . . . . . . . . . . . . . . 115
5.3.3 Implémentation de SkelGIS pour les réseaux . . . . . . . . . . . . . . . 122
5.4 Simulation 1D d’écoulement du sang dans les artères . . . . . . 123
5.4.1 Simulation 1D d’écoulement du sang dans le réseau artériel . . . . . . . 123
5.4.2 Parallélisation avec SkelGIS . . . . . . . . . . . . . . . . . . . . . . . 125
5.4.3 Résultats . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 129
5.5 Partitionnement de réseaux . . . . . . . . . . . . . . . . . . . . . . . 137
viii5.5.1 Partitionnement par regroupement d’arêtes sœurs . . . . . . . . . . . . 137
5.5.2 Partitionnement avec Mondriaan . . . . . . . . . . . . . . . . . . . . . 141
5.6 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 150
6 Conclusion 153
6.1 Bilan . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 154
6.2 Perspectives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 156
Bibliographie 159
Liste des figures
2.1 De la gauche vers la droite : maillage cartésien, curvilinéaire et non-structuré. 21
2.2 Maillages en théorie des graphes. . . . . . . . . . . . . . . . . . . . . . . . 23
2.3 Discrétisation régulière de Ω = {x : x ∈ [0, 1]}. . . . . . . . . . . . . . . . . 26
2.4 Discrétisation régulière de Ω = {(x, y) : (x, y) ∈ [0, 1]2}. . . . . . . . . . . . 27
2.5 Interprétation de la seconde forme intégrale de la loi de conservation. . . . 29
2.6 Discrétisation en cellules à volume fini suivant x et t. . . . . . . . . . . . . 29
2.7 Illustration de la méthode des éléments finis pour un cas simple à une
dimension. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
2.8 Fonctions de base Φj de type “tente”. . . . . . . . . . . . . . . . . . . . . . 31
2.9 Graphe donnant un exemple de partitionnement où la métrique edge-cut
ne représente pas le volume de communication. . . . . . . . . . . . . . . . 37
2.10 De gauche à droite : partitionnement en blocs, en blocs-lignes et bissection
récursive orthogonale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
2.11 De gauche à droite et de haut en bas : le maillage non structuré 2D, son
graphe nodal, son graphe dual et son graphe dual-diagonal. . . . . . . . . 42
2.12 Placement de notre travail par rapport à l’existant. . . . . . . . . . . . . . 62
3.1 Vue utilisateur (à gauche) et vue réelle (à droite) d’un programme SIPSim. 70
4.1 Deux types de connectivités pour les DMatrix de SkelGIS . . . . . . . . . 75
4.2 Décomposition d’un domaine à deux dimensions. . . . . . . . . . . . . . . 76
4.3 Exemple d’itérateur permettant de se déplacer dans trois éléments contigus
puis d’effectuer un saut de deux éléments avant la lecture contiguë suivante. 79
ix4.4 Spécialisation partielle de template pour l’objet DMatrix : T est le type
de donnée à stocker dans l’instance, Or est l’ordre de la simulation, et
box est le type de connectivité souhaitée. Ce paramètre a une valeur par
défaut à false (star est le choix par défaut). . . . . . . . . . . . . . . . . . 81
4.5 Fonction main du programme de résolution de l’équation de la chaleur
avec SkelGIS. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 84
4.6 Opération Laplacien pour la résolution de l’équation de la chaleur. . . . . 85
4.7 Logarithme des temps d’exécution de l’expérience 1 pour Heat_MPI et
Heat_SK. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
4.8 Logarithme des temps d’exécution de l’expérience 2 pour Heat_MPI et
Heat_SK. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
4.9 Logarithme des temps d’exécution de l’expérience 3 pour Heat_MPI et
Heat_SK. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
4.10 Accélération de la simulation SkelGIS pour les trois expériences. . . . . . . 88
4.11 Déclaration des variables h, u et v . . . . . . . . . . . . . . . . . . . . . . 92
4.12 Logarithme des temps d’exécution de l’expérience 1 pour FS_MPI et
FS_SK. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 96
4.13 Logarithme des temps d’exécution de l’expérience 2 pour FS_MPI et
FS_SK. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 97
4.14 Logarithme des temps d’exécution de l’expérience 3 pour FS_MPI et
FS_SK. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 97
4.15 Logarithme des temps d’exécution de l’expérience 4 pour FS_MPI et
FS_SK. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 97
5.1 Illustration d’un réseau à gauche et d’un exemple de simulation multiphysique
à droite avec deux types de discrétisation. Les nœuds subissent
une discrétisation cartésienne de l’espace et les arêtes subissent une discrétisation
non-structurée de l’espace. . . . . . . . . . . . . . . . . . . . . 103
5.2 Maillages et réseaux. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 104
5.3 Voisinage d’un nœud et d’une arête d’un réseau. . . . . . . . . . . . . . . . 110
5.4 Voisinage pour le cas particulier d’un maillage 1D dans les arêtes. . . . . . 110
5.5 Définition des objets DPMap_Edges et DPMap_Nodes. . . . . . . . . . . 111
5.6 Spécialisations partielles de template pour l’objet DPMap_Edges . . . . . 112
5.7 Graphe non orienté G et sa matrice d’adjacence Sp(G). . . . . . . . . . . 114
5.8 Graphe orienté acyclique correspondant au graphe 5.7. . . . . . . . . . . . 116
5.9 DAG global partitionné pour quatre processeurs. Le processeur 1 récupère
la partie bleue de ce partitionnement. . . . . . . . . . . . . . . . . . . . . . 117
5.10 Sous-graphe géré par le processeur 1 avant et après ré-indexation. . . . . . 118
5.11 Parallélisation de la structure et ré-indexation. . . . . . . . . . . . . . . . 119
5.12 Système de ré-indexation. . . . . . . . . . . . . . . . . . . . . . . . . . . . 120
5.13 Déclaration des variables A et Q . . . . . . . . . . . . . . . . . . . . . . . 126
5.14 Déclaration d’une variable nd sur les nœuds du réseau . . . . . . . . . . . 127
x5.15 Accélération de la simulation bloodflow-SkelGIS sans et avec le recouvrement
des communications par les calculs. . . . . . . . . . . . . . . . . . . . 131
5.16 Comparaison des temps d’exécution entre bloodflow-OpenMP et
bloodflow-SkelGIS avec une échelle logarithmique. . . . . . . . . . . . . . . 132
5.17 Comparaison des accélérations entre bloodflow-OpenMP et bloodflowSkelGIS.
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 133
5.18 Accélération de bloodflow-SkelGIS sur un DAG de 15k arêtes et nœuds
sur le TGCC. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 136
5.19 Accélération de bloodflow-SkelGIS sur des DAGs de 50k et 100k arêtes et
nœuds sur Juqueen. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 136
5.20 Accélération de bloodflow-SkelGIS sur un DAG de 500k arêtes et nœuds
sur Juqueen. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 136
5.21 Exemple de réseau G (à gauche) de type DAG, et du méta-graphe G0
associé (à droite). . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 138
5.22 Moyenne (moy) et écart type (ect) du nombre d’arêtes obtenu pour chaque
processeurs suite au partitionnement. . . . . . . . . . . . . . . . . . . . . . 140
5.23 Moyenne (moy) et écart type (ect) du nombre d’octets à échanger pour
chaque processeur, pour chaque DPMap et pour une unique itération de
temps de la simulation, suite au partitionnement des arbres de la table 5.8. 140
5.24 Moyenne du nombre d’octets total à échanger pour chaque processeur,
dans le cadre de la simulation artérielle de la section 5.4, en utilisant les
arbres de la table 5.8. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 141
5.25 Transformation du graphe G d’un réseau en graphe G0
. . . . . . . . . . . . 143
5.26 Étapes de communication 1 et 3. . . . . . . . . . . . . . . . . . . . . . . . 143
5.27 Exemple de réseau G0 avec la matrice A et les hypergraphes Hr et Hc qui
y sont associés . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 145
5.28 Problème de partitionnement pour les nœuds bleus de G0
. . . . . . . . . . 146
5.29 La matrice W et le graphe biparti complet auquel la matrice peut être
identifiée Gw. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148
xi1
Introduction
Sommaire
2.1 Calcul parallèle : architectures et programmation . . . . . . . 8
2.1.1 Architectures parallèles . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.1.2 Paradigmes et modèles de parallélisation . . . . . . . . . . . . . . . . . . 13
2.2 Problèmes numériques et Simulations scientifiques . . . . . . . . 19
2.2.1 Équations aux dérivées partielles . . . . . . . . . . . . . . . . . . . . . . 19
2.2.2 Passage du continu au discret . . . . . . . . . . . . . . . . . . . . . . . . 20
2.2.3 Méthodes numériques basées sur les maillages . . . . . . . . . . . . . . . 23
2.2.4 Exemples de méthodes numériques basées sur des maillages . . . . . . . 25
2.2.5 Programmation et parallélisation . . . . . . . . . . . . . . . . . . . . . . 31
2.3 Distribution de données . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
2.3.1 Modèles de partitionnement . . . . . . . . . . . . . . . . . . . . . . . . . 35
2.3.2 Cas particulier du partitionnement de matrices . . . . . . . . . . . . . . 38
2.3.3 Cas particulier du partitionnement de maillages . . . . . . . . . . . . . . 40
2.3.4 Partitionnements particuliers . . . . . . . . . . . . . . . . . . . . . . . . 43
2.4 Le parallélisme implicite . . . . . . . . . . . . . . . . . . . . . . . . . . . 44
2.4.1 Classification de problèmes et aide à la parallélisation . . . . . . . . . . 45
2.4.2 Solutions partiellement implicites . . . . . . . . . . . . . . . . . . . . . . 46
2.4.3 Solutions générales de parallélisme implicite . . . . . . . . . . . . . . . . 47
2.4.4 Solutions à patrons . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
2.4.5 Solutions spécifiques à un domaine . . . . . . . . . . . . . . . . . . . . . 53
2.5 Calculs de performances et difficulté de programmation . . . 56
2.5.1 Mesures de performances . . . . . . . . . . . . . . . . . . . . . . . . . . . 56
2.5.2 Effort de programmation . . . . . . . . . . . . . . . . . . . . . . . . . . . 59
2.6 Conclusion et positionnement du travail . . . . . . . . . . . . . . . 61
12 Chapitre 1. Introduction
1.1 Contexte de la recherche
Les calculs scientifiques, notamment dans le domaine de la simulation numérique,
ont toujours été consommateurs de ressources informatiques. Dans les années 60, à l’apparition
des premiers super-calculateurs, les calculs les plus demandeurs en ressources
étaient déjà les calculs scientifiques. Depuis, ce besoin de puissance de calcul n’a fait
qu’augmenter, donnant naissance à des architectures parallèles toujours plus complexes
à utiliser. La demande de puissance de calcul, ou de performance dans les architectures
parallèles, est a priori sans limites pour plusieurs raisons. Tout d’abord, les données utilisées
par les scientifiques sont de plus en plus précises et donc de plus en plus lourdes et
longues à traiter. Cette précision des données vient, par exemple, de la progression des
techniques d’acquisition, comme pour les données géo-référencées obtenues par des technologies
à laser (la télédétection LIDAR, par exemple). Cette précision peut également
être obtenue en complexifiant le maillage associé au domaine d’étude dans les simulations
numériques. En effet, dans les simulations, le domaine d’étude est généralement discrétisé
en un maillage afin de pouvoir être traité numériquement. Plus ce maillage est précis,
plus la simulation est précise et plus les calculs à effectuer sont nombreux, et il est en
pratique possible d’augmenter très largement la précision du maillage (sous réserve des
capacités de précision du matériel). De plus, il est évident qu’il est également possible
d’augmenter la taille du domaine d’étude de façon importante, ce qui est également à
l’origine des besoins croissants de puissance et de parallélisme. Enfin, si les maillages des
simulations numériques peuvent être complexifiés, les méthodes numériques et les calculs
peuvent l’être également. Un exemple très parlant de demande croissante de puissance
de calcul est le traitement des modèles météorologiques. Il est potentiellement toujours
possible d’ajouter des facteurs aux modèles, et de les complexifier, mais également d’augmenter
la taille du domaine étudié etc. Toutes ces modifications rendent les calculs plus
longs, demandant plus de puissance et plus de parallélisation, mais permettent d’obtenir
des prévisions plus précises.
Il résulte donc de cette demande croissante de puissance, la création d’architectures
parallèles, c’est-à-dire des architectures qui intègrent plusieurs processeurs, voire plusieurs
machines. De cette manière, il est possible d’obtenir plus de puissance de calcul
sans attendre les prochaines générations de processeurs. L’évolution de ces machines parallèles
nous a mené à des architectures matérielles de plus en plus compliquées et parfois
hétérogènes, mélangeant alors plusieurs machines (cluster ou cloud computing), plusieurs
cœurs et plusieurs processeurs mais aussi des calculateurs graphiques (GPU ). Les scienti-
fiques se retrouvent ainsi à devoir utiliser des architectures parallèles très complexes pour
pouvoir proposer des résultats pertinents et intéressants pour la communauté, sans en
maîtriser l’utilisation, comme d’ailleurs la plupart des informaticiens également. Le calcul
parallèle est donc devenu rapidement un domaine d’expertise que peu de personnes maî-
trise. Il n’est par conséquent pas envisageable que chaque scientifique non-informaticien
apprenne à programmer sur ces architectures et devienne un expert du parallélisme, par
manque de temps, d’argent, et de personnes pouvant les former. De plus, si une formation
de base est quant à elle envisageable, elle ne sera pas suffisante pour exploiter la pleine1.2. Contributions 3
puissance de ces machines. Même si cela se pratique dans certains cas, il paraît également
délicat d’imaginer des collaborations entre des scientifiques non-informaticiens et des experts
du parallélisme pour chaque code parallèle nécessaire. Pour ces raisons sont nés,
quasiment avec l’apparition des architectures parallèles, des outils et langages facilitant
leur programmation. Des modèles de programmation ont tout d’abord été proposés, puis
des langages et des bibliothèques parallèles, mais la complexité grandissante des architectures
a également fait évoluer ces solutions vers des solutions de parallélisme implicite qui
cachent partiellement ou totalement le parallélisme à l’utilisateur. Nous parlons alors de
parallélisme implicite partiel, lorsque les outils proposés cachent partiellement les technicités
du parallélisme, ou total, lorsque les outils cachent intégralement le code parallèle
à l’utilisateur. Nous classons les solutions de parallélisme implicite total suivant trois
grands types : les bibliothèques générales ; les solutions à patrons ; et les langages et bibliothèques
spécifiques. Dans le premier type une grande flexibilité est proposée et les
solutions permettent de s’adresser à tout type de traitements. Le second type propose,
quant à lui, un niveau d’abstraction très intéressant, et permet également de structurer
le code et de donner des repères simples à l’utilisateur. Enfin, le troisième type de parallélisme
implicite total est spécifique à un problème précis et propose des optimisations et
une efficacité pour ce problème. Le langage ou la bibliothèque est également plus simple
d’utilisation car proche du problème spécifique. Il s’agit des solutions les plus efficaces
mais également les moins flexibles.
1.2 Contributions
Cette thèse se place dans le cadre du parallélisme implicite pour les simulations numé-
riques. Nous proposons, tout d’abord, dans ce travail de thèse un modèle de programmation
parallèle implicite, nommé SIPSim pour Structured Implicit Parallelism for scientific
Simulations. Ce modèle présente une approche systématique pour proposer des solutions
de parallélisme implicite pour les simulations numériques basées sur des maillages. Le
modèle est basé sur quatre composants permettant de cacher intégralement le parallé-
lisme à l’utilisateur et d’obtenir un style de programmation proche du séquentiel. Le
modèle SIPSim se positionne à l’intersection des travaux existants en tentant de conserver
les avantages de chacun. Ainsi, le modèle a la particularité d’être à la fois efficace,
car spécifique au cas des simulations numériques à maillages, d’un niveau d’abstraction
permettant de conserver une programmation proche du séquentiel, ce qui garantit un
effort de programmation faible, tout en restant très flexible et adaptable à tout type de
simulations numériques.
Afin de valider le modèle SIPSim, une implémentation est proposée dans cette thèse,
sous le nom de SkelGIS. SkelGIS est une bibliothèque C++ constituée uniquement de
fichiers d’en-tête, ou autrement appelée header-only, implémentée en suivant le modèle
SIPSim pour deux cas de maillages différents : les maillages cartésiens à deux dimensions,
et les compositions de maillages issues des simulations sur des réseaux. Dans le premier
cas, de nombreuses solutions de parallélisme implicite existent, toutefois, en suivant le4 Chapitre 1. Introduction
modèle SIPSim, SkelGIS se place à l’intersection de plusieurs solutions et mêle à la fois
l’efficacité et la flexibilité de façon intéressante. Le deuxième cas, quant à lui, initie un
travail sur des simulations d’un genre plus complexe, où une composition de maillages est
effectuée. La flexibilité du modèle SIPSim est alors mise en avant, et ce type de travaux
n’a, à notre connaissance, jamais été proposé en parallélisme implicite. La bibliothèque
SkelGIS est évaluée en termes de performance et d’effort de programmation sur deux cas
réels d’application, développés et utilisés par des équipes de recherche en mathématiques
appliquées. Ces évaluations permettent donc, tout d’abord, de valider que SkelGIS (et le
modèle SIPSim) répond aux besoins de simulations complexes, et donc aux besoins des
scientifiques. Les performances obtenues sont comparées à des implémentations MPI et
OpenMP des mêmes simulations. Sur l’ensemble de ces évaluations, et pour un effort de
programmation beaucoup moins important, les performances obtenues sont très compé-
titives et proposent de meilleurs temps d’exécution. Nous montrons ainsi que SkelGIS
propose des solutions efficaces et flexibles, tout en cachant intégralement le parallélisme
à l’utilisateur et tout en conservant un style de programmation séquentiel.
La plupart des travaux présentés dans cette thèse ont fait l’objet de publications.
Publications dans des conférences internationales
1. Hélène Coullon, Sébastien Limet. Implementation and Performance Analysis of
SkelGIS for Network Mesh-based Simulations. Euro-Par 2014.
2. Hélène Coullon, Jose-Maria Fullana, Pierre-Yves Lagrée, Sébastien Limet, Xiaofei
Wang. Blood Flow Arterial Network Simulation with the Implicit Parallelism
Library SkelGIS. ICCS 2014.
3. Hélène Coullon, Sébastien Limet. Algorithmic skeleton library for scientific simulations
: Skelgis. In HPCS, pages 429-436. IEEE, 2013.
4. Hélène Coullon, Minh-Hoang Le, Sébastien Limet. Parallelization of shallow-water
equations with the algorithmic skeleton library skelgis. In ICCS, volume 18 of Procedia
Computer Science, pages 591–600. Elsevier, 2013.
Publications dans des journaux internationaux
1. Stéphane Cordier, Hélène Coullon, Olivier Delestre, Christian Laguerre, MinhHoang
Le, Daniel Pierre, and Georges Sadaka. Fullswof paral : Comparison of two
parallelization strategies (mpi and skelgis) on a software designed for hydrology applications.
ESAIM : Proceedings, 43 :59–79, 2013.
Publications en cours d’évaluation dans des journaux internationaux
1. Hélène Coullon, Minh-Hoang Le, Sébastien Limet. Implicit parallelism applied to
shallow-water equations using SkelGIS. In Concurrency and Computation : Practice
and Experience.1.3. Organisation du manuscrit 5
1.3 Organisation du manuscrit
Ce manuscrit est organisé en cinq chapitres supplémentaires dont voici le contenu :
— Nous étudions dans le chapitre 2 l’état de l’art nécessaire à la bonne compréhension
de ce manuscrit. Cet état de l’art est composé d’un historique et d’une présentation
des architectures parallèles, et des modèles et outils de parallélisation qui y sont
associés. Nous donnons ensuite une introduction sur les simulations numériques
basées sur des maillages, ce qui permet de rendre plus clairs les cas d’application
de cette thèse. Les bases nécessaires pour la compréhension des problèmes de dé-
compositions de domaines et de partitionnements de graphes, évoqués dans cette
thèse, sont ensuite abordés. Une description détaillée des solutions de parallélisme
implicites disponibles dans la littérature est ensuite proposée et permettra le positionnement
de notre travail dans ce contexte. Enfin, nous présentons les mesures
de performance et d’effort de programmation utilisées dans ce manuscrit.
— Le chapitre 3 présente le modèle de programmation implicite SIPSim. Il détaille
pour cela les quatre composants qui le constitue et le type de programmation
obtenu en adoptant ce modèle.
— Dans le chapitre 4 est ensuite détaillé la première implémentation du modèle
SIPSim, SkelGIS, pour le cas des maillages à deux dimensions cartésiens. Deux
cas d’application réels sont également détaillés et évalués dans ce chapitre.
— Le chapitre 5 continue la description de l’implémentation SkelGIS dans le cas
des simulations sur les réseaux, afin de valider davantage le modèle SIPSim. Une
section particulière précise l’implémentation et l’optimisation de la structure de
données distribuée, puis une section supplémentaire offre de nouveau un cas d’application
réel afin d’évaluer la solution. Pour terminer ce chapitre, le problème
de partitionnement des réseaux est évoqué et deux solutions plus récentes sont
présentées.
— Enfin, dans le chapitre 6 est présenté un bilan des travaux présentés dans cette
thèse, mais également un ensemble de perspectives de recherche.2
Etat de l’art
Sommaire
3.1 Structure de données distribuée . . . . . . . . . . . . . . . . . . . . . 66
3.2 Application de données . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
3.3 Applicateurs et opérations . . . . . . . . . . . . . . . . . . . . . . . . . 68
3.4 Interfaces de programmation . . . . . . . . . . . . . . . . . . . . . . . . 68
3.5 Vue utilisateur et vue réelle . . . . . . . . . . . . . . . . . . . . . . . 69
3.6 Type de programmation . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70
3.7 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
78 Chapitre 2. Etat de l’art
Dans ce chapitre vont être introduites les notions nécessaires à la bonne compréhension
de ce manuscrit. Afin, tout d’abord, de comprendre les problématiques engendrées
par l’utilisation des machines parallèles, nous étudierons rapidement les évolutions des
architectures parallèles, et les modèles algorithmiques et de programmation qui en dé-
coulent. Par la suite, nous étudierons des notions sur le calcul et la simulation scientifique,
qui représentent les domaines d’application de cette thèse. Deux états de l’art précis seront
ensuite nécessaires, sur la décomposition de domaine, et le parallélisme implicite.
Ces notions représentent, en effet, les problématiques informatiques principales de cette
thèse. Enfin, cette thèse présentera un certain nombre de résultats expérimentaux, nous
terminerons donc ce chapitre par une discussion des choix effectués pour l’évaluation des
performances et de la difficulté de programmation des solutions.
2.1 Calcul parallèle : architectures et programmation
Dans cette première section de l’état de l’art, nous allons introduire les notions de base
du parallélisme, nécessaires à la compréhension de cette thèse. Nous commencerons par
un historique rapide des architectures parallèles, puis une description des architectures
actuelles. Nous décrirons ensuite les principaux modèles de parallélisation disponibles
pour programmer ces architectures.
2.1.1 Architectures parallèles
C’est en 1965 qu’a été exprimée dans “Electronics Magazine” la première loi de Moore,
qui n’était alors qu’un postulat fondé sur une simple observation. En effet, Gordon Moore
constata que la complexité des semiconducteurs doublait tous les ans, depuis leur apparition
en 1959. Le postulat consistait alors à supposer la poursuite de cette croissance.
C’est également dans les années 60 que sont apparus les premiers super-calculateurs,
dont le but initial était d’effectuer une exécution la plus rapide possible des instructions
d’un programme. Toutefois, bien que le monde informatique se basait alors sur la loi de
Moore, et donc sur la montée en puissance des semi-conducteurs (puis plus tard sur la
miniaturisation des transistors), l’idée de machine parallèle est apparue très rapidement.
En effet, de par la demande grandissante de performances, notamment pour les calculs
scientifiques, il est vite devenu difficile de devoir attendre la sortie d’une nouvelle gamme
de processeurs pour obtenir plus de puissance de calcul. Il n’était, de plus, pas envisageable
de racheter l’ensemble du matériel régulièrement. Ainsi, l’idée de faire collaborer
ensemble plusieurs ordinateurs pour obtenir un résultat plus rapidement se concrétisa
dans les années 60. On parla alors, pour la première fois, d’architectures multiprocesseurs,
un terme qui désignait alors une architecture SMP (Symetric Multi Processor ).
Encore utilisée à l’heure actuelle, sous une forme plus moderne, l’architecture SMP représente
un ensemble de processeurs identiques dans une même machine, qui partagent
une mémoire vive commune.
Dans les années 1970, l’architecture des super-calculateurs évolua, proposant l’utilisation
de processeurs vectoriels. Ces processeurs furent les plus puissants de leur génération2.1. Calcul parallèle : architectures et programmation 9
et connurent un grand succès. Leur puissance était due à leur capacité à appliquer une
même instruction à des parties différentes des données, de façon simultanée. Plusieurs
processeurs vectoriels ont ensuite été utilisés en parallèle pour obtenir toujours plus de
puissance.
Ce n’est qu’en 1975, et suite à l’apparition des transistors, que Gordon Moore ré-
évalua sa première loi sous la forme d’une deuxième loi qui supposait que le nombre de
transistors sur une puce pouvait doubler tous les deux ans. Une mauvaise interprétation
de cette loi fût longtemps énoncée. Un amalgame y était effectué entre le nombre de
transistors sur une puce et la fréquence d’horloge d’un processeur. Cette loi erronée s’est
pourtant avérée exacte jusqu’au début des années 2000, avant de poser des difficultés de
dissipation thermique pour des fréquences trop importantes. Dans les années 80, suite
à la miniaturisation des transistors, sont apparus les microprocesseurs. Leur puissance
était modeste, mais leur faible encombrement et leur faible consommation ont permis
l’apparition, puis la démocratisation, des ordinateurs personnels. Le perfectionnement
des techniques de miniaturisation a permis une croissance importante de la puissance des
microprocesseurs. Les microprocesseurs sont ainsi devenus très rapidement compétitifs,
en terme de performances, face aux processeurs vectoriels. Leur faible coût de fabrication
en ont fait les processeurs les plus utilisés dans les architectures parallèles. Nous allons
décrire avec plus de précision, dans la suite de cette section, les différentes architectures
parallèles issues de l’apparition des microprocesseurs.
2.1.1.1 Architectures à mémoire partagée
Architectures SMP. Comme nous l’avons décrit précédemment, cette architecture
parallèle (la plus ancienne), consiste à regrouper plusieurs processeurs au sein d’une
même machine et de leur faire partager une même mémoire vive. Cette architecture a
naturellement été étendue aux microprocesseurs, toutefois, il n’est pas possible d’utiliser
cette architecture parallèle en augmentant à l’infini le nombre de processeurs. En effet, les
processeurs d’une architecture SMP entrent tous en concurrence pour lire et écrire dans la
mémoire commune, ce qui implique que l’ajout de processeurs à cette architecture ne peut
être efficace que si la mémoire est capable d’alimenter les processeurs supplémentaires
en données à traiter. Les microprocesseurs ont très rapidement été plus rapides que les
mémoires, créant une limitation physique à cette architecture.
Architectures NUMA. Les architectures à mémoire non uniforme, NUMA (Non Uniform
Memory Access), définissent pour chaque processeur (ou petit groupe de processeurs)
l’attribution d’une sous-partie de mémoire en accès direct et très rapide. Chaque
processeur ne pourra accéder aux données des autres mémoires qu’à travers un bus d’interconnexion,
plus lent. Cette architecture vise à améliorer l’architecture SMP en réduisant
le goulot d’étranglement dû à la mémoire commune à l’ensemble des processeurs.
Architectures multi-cœurs et many-cœurs. Dans le but d’augmenter la puissance
des microprocesseurs, sans en augmenter la fréquence, le concept de cœurs multiples10 Chapitre 2. Etat de l’art
(multicore) est apparu en 2001. Cette architecture peut être vue comme une unique
puce regroupant plusieurs microprocesseurs. Dans cette architecture, la proximité des
cœurs de calcul permet une communication plus rapide entre les différentes mémoires
des différents cœurs. Certaines architectures proposent même un système de mémoire
cache partagée par les cœurs. Le principe de la mémoire cache, ou mémoire tampon, est
de fournir dans les architectures modernes une mémoire très proche des processeurs (ou
cœurs) et permettant un accès plus rapide aux données. Cette mémoire, comme son nom
l’indique sert de tampon entre la mémoire vive et les unités de calcul, elle réduit donc
les délais d’accès aux données. La mémoire cache est composée de plusieurs niveaux. Le
niveau L1, ou cache interne, est le plus rapide et le plus proche des unités de calcul.
La mémoire cache fonctionne par chargement de lignes de cache. Une ligne de cache est
de taille limitée et dépend du matériel présent dans le processeur. Lorsqu’un processeur
a besoin d’accéder à une donnée pour une opération arithmétique, cette donnée, et ses
données contiguës en mémoire, sont chargées dans une ligne de cache (suivant sa limite de
taille). Si une donnée non présente dans le cache est nécessaire (on parle alors de défaut
de cache), une ligne de cache est invalidée et une nouvelle ligne devra à son tour être
chargée etc. Ces nouveaux processeurs ont permis, tout comme l’architecture NUMA,
de réduire le goulot d’étranglement des architectures SMP. De plus ces architectures ont
permis de démocratiser les architectures parallèles dans les ordinateurs personnels. Plus
récemment sont apparus les architectures multiprocesseurs Intel MIC pour Intel Many
Integrated Core Architecture, dont, par exemple, le très médiatique accélérateur Xeon
Phi. Ces architectures regroupent plusieurs processeurs possédant chacun un très grand
nombre de cœurs. On parle alors d’architectures many-cœurs. Le nombre total de cœurs
est très important dans ces architectures et donne accès au calcul massivement parallèle
(tout comme les accélérateurs graphiques dont nous parlerons par la suite), sans pour
autant devoir apprendre de nouvelles interfaces de programmation (contrairement aux
accélérateurs graphiques).
2.1.1.2 Architectures à mémoire distribuée
Architecture en grappe. L’architecture en grappe, aussi appelée un cluster en anglais,
consiste à connecter entre elles, par un réseau d’interconnexion à très haut débit,
plusieurs machines (ou nœuds) indépendantes matériellement homogènes. Dans une architecture
en grappe, et contrairement à une architecture à mémoire partagée, chaque
nœud est indépendant et possède donc sa propre mémoire à laquelle les autres nœuds
n’ont pas accès. Les nœuds sont donc amenés, dans ce type d’architectures, à communiquer
au travers du réseau d’interconnexion. Un réseau à très haute performance étant
très coûteux, les machines d’une grappe sont géographiquement les plus proches possible,
dans une même pièce ou dans une même armoire de rangement. Il s’agit d’une
approche simple mais très favorable au calcul haute performance pour plusieurs raisons.
Tout d’abord, outre la rapidité du réseau, un travail sur la topologie des réseaux peut
rendre plus rapides les communications entre certains nœudss du cluster. Une topologie
proche de la topologie des données utilisées peut donc, par exemple, favoriser les performances
d’un parallélisme de données. De plus, une grappe est composée d’un grand2.1. Calcul parallèle : architectures et programmation 11
nombre de processeurs identiques ce qui permet de favoriser l’optimisation d’un type
de matériel donné. Avec la démocratisation des architectures multi-processeurs et multicœurs,
les grappes sont aujourd’hui composées d’un certain nombre de nœuds ou d’unités
de calcul indépendantes à mémoire partagée. En théorie, ces architectures peuvent donc
être considérées comme des architectures à mémoire distribuée, mais également comme
des architectures à mémoire hybride.
Architecture en grille. Le concept de grille est un concept proche du concept de
grappe. Toutefois, une grille consiste à relier entre elles des ressources de calcul hété-
rogènes (ordinateurs, grappes, serveurs etc.) et potentiellement distantes. En effet, le
but d’une grille est d’utiliser la puissance de calcul disponible à plusieurs endroits pour
proposer une unique architecture virtuelle possédant des ressources de calcul très importantes
et extensibles. L’utilisateur d’une grille ne fait que soumettre le lancement
de calculs et n’aura aucune information sur les machines utilisées pour son calcul. La
gestion de ce type d’architecture est donc très complexe et son utilisation est souvent
restreinte aux calculs massivement parallèles (embarrassingly parallel), qui consistent à
effectuer le même traitement un grand nombre de fois, ce qui ne provoque aucune communication
entre les ressources. Pour des calculs parallèles nécessitant des communications
entre les ressources de calcul, une unique ressource de la grille est généralement utilisée,
on retrouve alors la notion de serveur ou de cluster, par exemple. Les ressources d’une
grille pouvant être géographiquement éloignées, le réseau d’interconnexion reliant les ressources
n’est généralement pas un réseau à très haut débit, trop coûteux. Toutefois, la
plateforme expérimentale Grid’5000 relie, par exemple, une dizaine de grappes de plusieurs
villes françaises au travers du réseau à haut débit RENATER (Réseau national de
télécommunications pour la technologie, l’enseignement et la recherche).
Cloud computing. Le nuage, plus communément appelé le cloud, est un service mettant
à disposition des ressources de calcul ou de stockage distantes. Le cloud ressemble
donc aux concepts de grille et de cluster. Toutefois, il se distingue par plusieurs points.
Tout d’abord, le concept de cloud est un service grand public qui s’ouvre à tous et qui
est généralement payant. Les clusters, comme les grilles, sont souvent des outils réservés
à des utilisateurs précis sur une période de temps limitée. L’accès y est gratuit mais une
demande doit être mise en place pour utiliser ce type d’architectures. De plus, le cloud,
contrairement à la grille, n’a pas été pensé pour l’accès à des ressources délocalisées.
Très souvent les ressources d’un cloud appartiennent à une unique entité et sont regroupées
géographiquement dans un endroit (bien que des travaux regroupant plusieurs cloud
existent également). De même, le cloud se distingue de la grille par le fait qu’il n’est pas
été mis en place dans l’idée de relier des architectures hétérogènes. Le cloud est donc à
la frontière des clusters et des grilles, mais dans une optique de service commercial et
grand public.12 Chapitre 2. Etat de l’art
2.1.1.3 Architectures hétérogènes
Retour de la vectorisation. Toujours afin d’augmenter la puissance des microprocesseurs,
sans en augmenter la fréquence, les capacités de vectorisation ont été réintroduites
dans les microprocesseurs scalaires modernes. On peut notamment évoquer les instructions
SSE (Streaming SIMD Extension), qui sont associées à des registres de 128 bits sur
lesquels il est possible d’effectuer quatre opérations simultanées sur des nombres flottants
de 32 bits, ou deux opérations simultannées sur des nombres flottants de 64 bits etc. La
version la plus récente SSE4 donne accés à 47 types d’instructions. Les jeux d’instructions
AVX2 (Advanced Vector Extensions 2 ) et AVX-512, plus récents, proposent des
opérations simultanées sur des registres de 256 et 512 bits, ce qui augmente encore les
possibilités de calcul des microprocesseurs. Les registres vectoriels, ajoutés aux microprocesseurs
modernes, offrent une hétérogénéité d’architecture proposant des performances
très intéressantes.
Accélérateurs graphiques. Les processeurs graphiques, ou GPU, sont initialement
apparus pour effectuer des calculs performants et spécifiques à l’affichage graphique,
comme par exemple le rendu d’images en trois dimensions. Ils ont rapidement été massivement
parallélisés, de par la nature répétitive de leurs calculs. Initialement, ces processeurs
graphiques étaient cantonnés à un certain nombre de fonctionnalités, puis ils
sont devenus programmables, ce qui a initié une déviation de leur utilité première, pour
des calculs autres que graphiques. On ne parle alors plus de GPU mais de GPGPU. Un
GPGPU propose des unités de calcul alternatives aux CPU, et massivement parallèles.
Toutefois, il est important de noter qu’un GPU ne peut complètement s’abstraire d’un
CPU pour fonctionner, ce dernier permettant de charger des programmes et des données
en mémoire vive. Il s’agit donc d’une architecture parallèle hétérogène. Les GPU étant
peu coûteux et consommant peu d’énergie, leur utilisation dans les grands centres de calcul
internationaux devient fréquente. Dans ce cas, les GPGPU sont présents sur chaque
nœud du cluster et servent à effectuer les calculs. Les CPU, quant à eux servent à charger
en mémoire les programmes et les données et également à gérer les communications sur
le réseau d’interconnexion.
Architectures hybrides. Une architecture hétérogène, ou hybride apparaît comme
évidente après la description des architectures précédentes. Il s’agit de réutiliser l’approche
à mémoire partagée au sein de l’approche à mémoire distribuée. Cette architecture
permet d’augmenter le nombre total de processeurs utilisés et de répartir les faiblesses
sur plusieurs goulots d’étranglement au lieu d’un seul. Ce type d’architectures est devenu
une référence et est très utilisé parmi les grappes les plus puissantes du monde. Les
architectures hétérogènes peuvent être de type grappe/NUMA, grappe/multi-cœurs (ou
plus généralement grappe/CPU), mais également grappe/GPU etc.2.1. Calcul parallèle : architectures et programmation 13
2.1.2 Paradigmes et modèles de parallélisation
Avec l’apparition des architectures parallèles sont apparus les premiers problèmes de
programmation parallèle. En effet, un programme séquentiel en lui même, et sans l’aide
particulière du système d’exploitation ou de tout autre système externe de répartition de
charge sur les cœurs ou les processeurs, n’exploite pas directement les ressources d’une
machine parallèle. Or, la conception d’un programme parallèle peut s’avérer très complexe
et très dépendante des architectures utilisées. Avec l’apparition des machines parallèles
sont donc apparus également des paradigmes de programmation parallèle, offrant un ensemble
d’approches générales pour envisager un programme parallèle, puis des modèles de
programmation parallèle, permettant de concevoir de façon plus précise des programmes
sur ces machines. Les paradigmes de programmation parallèle représentent donc un niveau
d’abstraction plus bas et moins précis que les modèles de programmation parallèle.
Les modèles de programmation parallèle, même si certains sont naturellement induits
par le matériel, peuvent être implémentés pour différentes architectures parallèles. On
distingue donc les modèles de programmation des implémentations qui y sont associées
pour un modèle d’exécution donné. Par exemple un modèle de programmation parallèle
initialement pensé pour des architectures à mémoire distribuée pourrait être implémenté
sur une architecture DSM (Distributed Shared Memory) [79] qui permet de construire
un espace mémoire partagé pour tous les processeurs, bien que cet espace mémoire soit
physiquement distribué. Nous décrivons dans cette partie quelques uns des paradigmes
et des modèles de programmation parallèle les plus utilisés et les plus connus. Nous ne
décrirons en revanche pas leurs implémentations pour différents modèles d’exécution.
2.1.2.1 Paradigmes de programmation parallèle
Taxinomie de Flynn. En 1972, Michael J. Flynn définit une classification des architectures
des ordinateurs [54]. Quatre classes étaient alors répertoriées et sont représentées
dans la table 2.1. La première classe, nommée Single Instruction, Single Data (SISD) représente
les machines séquentielles n’exploitant aucun parallélisme. La deuxième classe,
Singe Instruction, Multiple Data (SIMD), représente les machines pouvant appliquer une
unique instruction à un ensemble de données. Cette classe concerne donc typiquement les
architectures vectorielles ou GPU. La troisième classe, Multiple Instruction, Single Data
(MISD), représente les machines permettant d’appliquer plusieurs instructions à la suite
sur un même donnée d’entrée. Cette classe concerne typiquement les programmes de type
pipeline ou les systèmes de tolérance aux pannes cherchant à comparer deux résultats
issus d’une même donnée. Enfin, la quatrième classe, Multiple Instruction, Multiple Data
(MIMD), représente les machines multiprocesseurs qui peuvent exécuter simultanément
des instructions différentes sur des données différentes.
Cette classification est toujours utilisée dans le parallélisme actuel, mais sous une
autre forme. En effet, les différentes classes ne sont plus représentatives d’architectures
matérielles particulières, la plupart des machines répondant à l’ensemble de ces classes. En
revanche, les classes de Flynn représentent désormais des paradigmes de programmation14 Chapitre 2. Etat de l’art
instruction unique instructions multiples
donnée unique SISD MISD
données multiples SIMD (SPMD) MIMD (MPMD)
Table 2.1 – Taxinomie de Flynn
parallèle, très souvent associés aux paradigmes de parallélisation de tâches et de données,
que nous allons décrire.
Parallélisme de tâches. Ce paradigme de programmation cherche à diviser un programme
en un ensemble de tâches qui peuvent être dépendantes, mais aussi indépendantes.
Dans ce cas c’est l’exécution du programme qui cherche à être parallélisée. Les
paradigmes de parallélisation MISD et MIMD peuvent être associés au parallélisme de
tâches. Dans le premier cas, des opérations successives sont appliquées sur un jeu de données
d’entrée, on appelle communément ce type de calcul un pipeline. L’instruction i + 1
ne peut alors être exécutée qu’une fois l’instruction i terminée. Toutefois, sur des données
d’entrées suffisamment nombreuses, le parallélisme peut apparaître en quinconce. En effet
étant donné une donnée d’entrée [x1, . . . , xn], une fois x1 calculé pour l’instruction i,
il est possible de calculer simultanément x2 pour l’instruction i et x1 pour l’instruction
i + 1. Le paradigme MIMD, quant à lui, est rencontré plus fréquemment et offre plus de
possibilités de parallélisation. Dans ce cas, on cherchera à identifier des tâches travaillant
sur des données différentes, ce qui rend les tâches indépendantes les unes des autres. Toutefois,
le développeur devra se charger de synchroniser les différentes tâches ensemble afin
de garantir la cohérence des résultats. Nous pouvons enfin noter la paradigme MPMD
(Multiple Program, Multiple Data), qui étend le concept MIMD à des programmes. Ainsi,
chaque processeur peut appliquer un ou plusieurs programmes qui lui sont propres à des
données éventuellement différentes des autres processeurs de façon indépendante. Les
synchronisations nécessaires au bon fonctionnement du programme parallèle sont alors à
la charge de l’utilisateur.
Parallélisme de données. Dans ce paradigme, le parallélisme se focalise sur la façon
dont les données sont distribuées sur les différents processeurs. L’ensemble des processeurs
effectuent alors le même jeu d’instructions sur des données d’entrée qui leurs sont propres.
Dans ce type de parallélisme les tâches effectuées par le programme sont peu modifiées.
Il faut toutefois réflechir et consevoir les communications, les échanges ou les synchronisations
nécessaires entre les processeurs pour que le programme parallèle soit correct
et donne le même résultat qu’en séquentiel. Les paradigmes SIMD et SPMD (Simple
Program, Multiple Data) sont associés au parallélisme de données. Ils représentent le
même type de parallélisation, toutefois SIMD est associé aux architectures vectorielles et
GPU, où la notion d’instruction est clairement définie et synchrone. L’approche SPMD
est plus vaste et moins tournée vers la solution matérielle. Elle peut s’appliquer à des
architectures à mémoire partagée comme distribuée. Ce paradigme considère une exécution
indépendante d’un programme sur chaque processeur, et sur des données différentes,2.1. Calcul parallèle : architectures et programmation 15
et met à la charge du programmeur les synchronisations nécessaires à la cohérence du
calcul général. Ce type de parallélisation est l’une des plus utilisée, notamment pour les
architectures à mémoire distribuée.
Paradigmes induits par le matériel. Nous abordons maintenant deux paradigmes
de programmation parallèle connus et induits par le matériel, qui sont utilisés par la plupart
des modèles présentés par la suite. Dans les architectures à mémoire partagée, les
processus peuvent interagir par l’écriture et la lecture dans des espaces mémoire partagés
et communs. Ces architectures permettent donc des interactions entre les processus par
la simple utilisation de la mémoire de la machine, mais font intervenir des problèmes
de concurrence d’accès aux données ainsi que de cohérence ou d’intégrité des données.
Le paradigme de programmation parallèle le plus utilisé pour gérer ces problématiques,
et que nous appelons paradigme à verrous, consiste à fournir des mécanismes permettant
d’assurer l’exclusion temporaire de l’accès aux données pour en garantir l’intégrité.
Le mécanisme le plus couramment utilisé se base sur des verrous d’exclusion mutuelle,
appelés mutex. Un verrou sur une variable n’est attribué qu’à un unique processus, ce
qui garantit qu’aucun autre processus ne pourra accéder ou écrire dans cette variable
jusqu’à l’obtention, à son tour, d’un verrou. Pour ce qui est des architectures à mémoire
distribuée, le paradigme le plus naturel, et parmi les plus utilisés, de passage de messages,
est venu du simple constat que, dans ces architectures, les processus ne partagent pas
d’espace d’adressage commun et qu’il est nécessaire d’échanger des messages pour que ces
processus puissent communiquer entre eux. Ce paradigme n’introduit donc pas de problèmes
de concurrence et d’intégrité des données, mais un problème de communication.
Le niveau d’abstraction le plus bas pour mettre en place ce paradigme consiste à utiliser
le réseau des machines et donc à faire, par exemple, appel à la programmation de sockets
Unix, qui permettent l’envoi d’octets à destinations d’une adresse réseau spécifique. De
nombreux modèles de programmation parallèle sont issus de ce paradigme.
2.1.2.2 Modèles de programmation parallèle
Mémoire partagée. Les modèles de programmation induits des architectures à mé-
moire partagée sont très souvent basés sur du parallélisme de tâches, mais peuvent également
se baser sur du parallélisme de données. Le standard des threads POSIX (ou
pthreads) [96] est l’un des modèles les plus répandus du parallélisme pour architectures
à mémoire partagée. Ce modèle est basé sur le paradigme de verrous évoqués dans la
partie précédente. Il permet de définir la création d’un nouveau processus léger (appelé
un thread), dont l’exécution sera gérée par le système, en suivant une politique d’ordonnancement.
A sa création, une tâche est assignée au thread et sera effectuée en parallèle
du programme principal, qui pourra continuer son exécution. Un certain nombre de routines
permettent ensuite des synchronisations entre les processus créés, et permettent de
poser des verrous pour la modification de données.
Le modèle de directives OpenMP [34], qui sera décrit avec précision dans la suite
de cette thèse, est le deuxième modèle très utilisé sur les architectures à mémoire partagée.
Il permet d’ajouter du multi-threading (le fait de créer plusieurs processus légers16 Chapitre 2. Etat de l’art
pour certaines tâches du programme) dans du code C, C++ ou Fortran, par l’ajout de
directives, sans modifications majeures du code, mais avec des résultats de performance
limités. Ce modèle est principalement basé sur la parallélisation de boucles, ou sur le
parallélisme de tâche dans lequel il est explicitement indiqué quels sont les différents
travaux disponibles pour les threads. Il est également demandé à l’utilisateur de déclarer
les variables locales et partagées du programme, afin de positionner automatiquement,
par la suite, des exclusions mutuelles pour l’accès aux données partagées.
Enfin, notons qu’il existe un modèle de programmation parallèle, nommé PGAS [6]
(Partitioined Global Adress Space), basé sur le concept d’espace d’adressage mémoire
global partitionné. Ce modèle propose une vision distribuée de la mémoire physiquement
partagée par les threads. Ce modèle suggère donc la création d’un espace d’adressage
virtuel partitionné global, auquel chaque thread a physiquement accès, mais dont les
traitements sont partitionnés pour chaque thread. Ce modèle permet donc d’éviter, en
grande partie, les problèmes de concurrence d’accès aux données, que l’on peut trouver
dans tous les modèles basés sur le paradigme à verrous. Ce modèle de programmation
est donc basé sur le paradigme de parallélisme de données, et s’implémente généralement
par la parallélisation d’un traitement sur un tableau ou un conteneur. Notons enfin que,
dans le modèle PGAS, le partitionnement proposé pour le traitement de données peut
changer aucours de l’exécution du programme parallèle.
Mémoire distribuée. Le modèle de programmation parallèle Message Passing Interface
(MPI) [61] est basé sur la paradigme de passage de messages et définit un protocole
de communication entre des processus indépendants et éventuellement distants. Ce modèle
a initialement été défini pour les architectures à mémoire distribuée, toutefois il
obtient également de très bonnes performances sur des architectures à mémoire partagée
et à mémoire hybride distribuée/partagée. Ce modèle est composé de communications
point-à-point, permettant de décrire l’envoi d’un message à un processeur précis, et de
communications collectives, permettant d’envoyer des informations à l’ensemble ou à une
sous-partie des autres processus. Notons que les communications point-à-point peuvent
être bloquantes ou non bloquantes pour permettre certaines optimisations dans les programmes
parallèles implémentés. Une communication non bloquante rendra la main avant
que la communication ne soit terminée, à l’inverse d’une communication bloquante. Il est
alors à la charge de l’utilisateur de s’assurer, aux endroits adéquats de son programme,
que la communication est terminée. MPI contient également des interfaces permettant
de créer des topologies entre les processus, de créer des types d’envois particuliers etc. Il
existe plusieurs implémentations génériques de cette norme, comme Open MPI [56] ou
MPICH2 [63], et il est de plus possible pour les constructeurs d’écrire leur propre implé-
mentation afin de l’optimiser à leur matériel. C’est notamment le cas de Intel MPI [22].
Ce modèle (et ses implémentations) a connu un grand succès depuis sa création dans les
années 90, et s’impose aujourd’hui comme l’un des outils de référence de la parallélisation,
tout particulièrement dans le domaine du calcul scientifique et de la haute performance.
Il est également important, pour la compréhension de cette thèse, de s’attarder sur
l’un des modèles de programmation les plus connus et les plus anciens, le modèle Bulk2.1. Calcul parallèle : architectures et programmation 17
Synchronous Parallel [92, 118], proposé par Valiant en 1990. Le modèle architectural de
BSP correspond naturellement à une machine à mémoire distribuée. En effet, dans ce
modèle la machine modélisée est une machine à mémoire distribuée composée d’un ensemble
de processeurs à mémoire indépendante. Toutefois, comme MPI, ce modèle peut
tout à fait s’appliquer à une architecture à mémoire partagée. Les caractéristiques d’une
machine BSP sont définies par quatre paramètres. Le premier, p, représente le nombre
de processeurs sur la machine. Le deuxième, r, représente la puissance d’un processeur
(mesurée en nombre d’opérations flottantes par seconde). L représente, quant à lui, le
temps nécessaire pour effectuer une synchronisation globale entre tous les processeurs. Et
pour finir, g représente le temps nécessaire pour l’envoi d’une donnée, du type souhaité,
sur le réseau. L’élément de base d’un algorithme ou d’un programme BSP est appelé une
super-étape (ou superstep). Un programme BSP est constitué d’une succession de super-
étapes qui peuvent être composées (1) de plusieurs phases de calculs indépendantes, (2)
de phases de communications, et (3) de phases de synchronisation entre les processeurs.
On distingue plus généralement des super-étapes de calculs, dans lesquelles chaque processeur
effectue une séquence d’opérations sur des données locales, et des super-étapes de
communications, où chaque processeur envoie et reçoit des messages. Quelle que soit la
représentation d’une super-étape, elle est toujours terminée par une synchronisation des
processeurs. Dans cette phase de synchronisation chaque processeur vérifie que l’ensemble
des tâches à accomplir sont terminées localement, et préviens les autres processeurs. Tous
les processeurs attendent les messages de terminaison des autres processeurs avant que la
super-étape ne se termine et qu’une autre puisse être commencée. Ce type de synchronisation
est appelé bulk synchronisation. L’une des forces du modèle BSP est de proposer une
fonction de coût calculée à partir des paramètres de la machine et de l’algorithme BSP
formulé en super-étapes. Étant donné une super-étape de calcul s, on note ω
(s)
le temps
d’exécution de la super-étape, qui est égal au temps maximum d’exécution, parmi tous les
processeurs. Nous avons alors ω
(s) = max
0≤i 0 représente le pourcentage de déséquilibre toléré, et en minimisant le nombre
d’arêtes coupées dans ce partitionnement. Cette dernière métrique, qui vise à couper le
moins d’arêtes possible lors du partitionnement, est aussi appelée la métrique edge-cut. Le
partitionnement standard de graphe est notamment implémenté dans les partitionneurs
Chaco [67], METIS [77] et Scotch [100].
La méthode standard de partitionnement de graphe a longtemps été la seule méthode
utilisée. Toutefois ses limites ont été très largement évoquées et résumées dans les travaux
de Hendrickson et Al [66]. La critique de cette approche repose sur deux faiblesses : la
métrique edge-cut et le modèle en lui-même. Nous n’allons pas évoquer ici l’ensemble des
faiblesses de la métrique et de la méthode de partitionnement, nous pouvons toutefois
noter deux points que nous considérons comme importants, et qui sont résolus par la
méthode de partitionnement d’hypergraphe décrite par la suite.
La première faiblesse que nous souhaitons évoquer concerne la métrique edge-cut.
Cette métrique dénombre les arêtes qui doivent être coupées suite au partitionnement
mis en place. La limite de cette métrique vient du fait qu’elle n’est pas proportionnelle au
volume de communication nécessaire dans un programme parallèle. En d’autres termes,
cette métrique ne modélise pas correctement les communications pour la plupart des
problèmes de partitionnement. Prenons un exemple afin d’illustrer cette caractéristique.
Étant donné le graphe représenté dans la figure 2.9, partitionné en trois parties, une pour
chaque processeur P0, P1 et P2. Étant donné que chaque arête e ∈ E représente un coût
de communication c(e) = 1 + 1 = 2 (afin de représenter une communication symétrique),
alors un partitionnement de graphe standard trouverait la métrique edge-cut comme
égale à c(e)×5 = 10. Dans cet exemple, pourtant, nous pouvons observer que le sommet
v2 est relié par deux arêtes à la partition du processeur P1, ce qui signifie qu’un unique2.3. Distribution de données 37
envoi de v2 est nécessaire dans l’implémentation. En procédant ainsi pour les sommets
v5 et v8 nous trouvons que le véritable volume de communication est égal à 7.
v1
v2 v3
v4
v8 v9 v6
v5
v7
P0
P1
P2
Figure 2.9 – Graphe donnant un exemple de partitionnement où la métrique edge-cut ne
représente pas le volume de communication.
La deuxième faiblesse que nous évoquerons ici est le fait que la méthode standard
de partitionnement de graphe ne permet d’exprimer que des dépendances symétriques.
Une arête représente, en effet, un envoi de données des deux sommets la constituant.
Ainsi la méthode de partitionnement manque d’expressivité pour certains problèmes
asymétriques.
2.3.1.2 Partitionnement d’hypergraphes
Un hypergraphe H = (V, N ) est composé d’un ensemble de sommets, ou nœuds,
noté V , et d’un ensemble N d’hyper-arêtes. Chaque hyper-arête est un sous-ensemble
de V . Une hyper-arête est donc une généralisation de la notion d’arête dans un graphe,
où plus de deux sommets de V peuvent être reliés entre eux. Dans le cas spécifique où
chaque hyper-arête contient exactement deux sommets, on revient alors à la définition
d’un graphe. Tout comme pour un graphe, tout sommet v ∈ V d’un hypergraphe peut
être pondéré par ω(v), et chaque hyper-arête n ∈ N peut, elle aussi, être associée à un
poids, ou un coût, que l’on note c(n). Ces poids sont généralement des réels positifs,
mais dans cette thèse nous considérerons ces poids comme des entiers naturels. Étant
donné un sous-ensemble S de V , ω(S) est défini comme la somme des poids de chacun
des sommets de S.
Le partitionnement p-way d’un hypergraphe H = (V, N ) est défini par p sousensembles
de V , V0, . . . , Vp−1 tels que pour tout i ∈ J0, pJ, Vi ⊂ V , Vi 6= ∅, et tels
que pour tout i, j ∈ J0, pJ, si i 6= j, alors Vi ∩ Vj = ∅. Le problème de partitionnement
d’un hypergraphe est alors de trouver un partitionnement p-way qui satisfasse la38 Chapitre 2. Etat de l’art
contrainte d’équilibrage (2.15), et qui minimise la métrique de coût suivante :
X
n∈N
c(n)(λ(n) − 1), (2.16)
où λ(n) est le nombre de parties connectées à une même hyper-arête n ∈ N ,
λ(n) = |{Vi
: 0 ≤ i < p et Vi ∩ n 6= ∅}|. (2.17)
Cette métrique, que l’on cherche à minimiser, est appelée la métrique-(λ − 1).
Le premier modèle de partitionnement d’hypergraphe est apparu dans les travaux
de Çatalyürek et Al [26] . Son efficacité pour modéliser certains problèmes de partitionnement
a été démontrée [28]. L’avantage principal de ce modèle est sa capacité à
représenter exactement le volume de communications, ce qui n’est pas le cas en utilisant
la métrique edge-cut du modèle de partitionnement de graphe. Reprenons, par exemple,
le graphe G = (V, E) de la figure 2.9, et construisons un hypergraphe H = (V, N ) où
|N | = |V |. L’ensemble des hyper-arêtes N est défini de façon à ce que chaque sommet
vi ∈ V corresponde à une hyper-arête hi ∈ N qui contient vi et l’ensemble de ses sommets
voisins dans G. Par exemple, l’hyper-arête du sommet v2 contient alors les sommets v2,
v8, v6 et v5. Cette hyper-arête contient donc des sommets des processeurs P2 et P1, son
coût est donc de 2. Lors du partitionnement, on retrouve alors la métrique de coût de
communication définie dans l’équation (2.16) qui est bien égale à 7 pour cet exemple.
Pour finir, la méthode de partitionnement d’hypergraphe permet la représentation de
problèmes asymétriques.
2.3.2 Cas particulier du partitionnement de matrices
Le modèle de partitionnement d’hypergraphes a été utilisé dans de nombreux travaux
afin de représenter les communications d’une multiplication de matrice creuse par un
vecteur [28]. Ce traitement est l’un des plus courants dans les calculs scientifiques et a
été très largement étudié. Un ensemble de méthodes de partitionnement, spécifiques à
ce problème, a été élaboré. Un parallèle pouvant être fait de plusieurs façons entre un
hypergraphe, ou un graphe, et une matrice creuse, les méthodes et les partitionneurs qui
ont été développés pour ce type de traitements permettent également de résoudre d’autres
problèmes de partitionnement. Dans cette thèse, le partitionneur Mondriaan [120], qui
implémente plusieurs de ces techniques, est utilisé. Nous allons donc décrire, dans cette
section, l’ensemble des modèles de partitionnement qui ont été mis en place pour le
problème de multiplication matrice creuse-vecteur. Nous ne décrirons pas, en revanche,
comment utiliser ces modèles sur la multiplication matrice creuse-vecteur en elle-même,
puisque cette thèse ne s’intéresse pas particulièrement à ce problème. Ces détails peuvent
être trouvés dans les travaux de Bisseling et Al [18].
2.3.2.1 Partitionnement à une dimension
Voyons une première façon de transformer une matrice vers un hypergraphe. Considé-
rons une matrice creuse A de taille m×n. Notons alors ai,j ses coefficients avec i ∈ J1, mJ2.3. Distribution de données 39
et j ∈ J1, nJ. On peut alors considérer que chaque colonne j de la matrice A est repré-
sentée par un sommet de l’hypergraphe avec un poids ω(j) égal au nombre de valeurs
non nulles dans la colonne j. Considérons ensuite que chaque ligne i de la matrice A est
représentée par une hyper-arête qui contient les sommets j pour lesquels ai,j 6= 0. Pour
finir, considérons que le coût d’une hyper-arête est égal à 1. Cette représentation d’une
matrice creuse A par un hypergraphe Hr est appelée le modèle row-net, qui signifie que
les hyper-arêtes (net) représentent les lignes de la matrice (row). Dans ce cas un partitionnement
p-way de l’hypergraphe Hr amène à un partitionnement à une dimension,
ou 1D, de la matrice A. Ce partitionnement distribue donc les colonnes de la matrice
A en p parties différentes. Chaque colonne étant pondérée par ω, le nombre de valeurs
non nulles dans la colonne, le partitionnement de Hr distribue de façon équilibrée les
valeurs non nulles de la matrice A en suivant la contrainte d’équilibrage définie dans
l’équation (2.15). Pour finir, le volume de communications causé par la séparation d’une
ligne de A dans plusieurs parties est minimisé par le fait qu’une ligne représente une
hyper-arête et minimise donc la métrique-(λ − 1) représentée dans l’équation (2.16).
Notons, pour terminer, qu’il est également possible de faire un partitionnement 1D de la
matrice A par le modèle column-net qui, à l’inverse, associe les lignes de A aux sommets
de l’hypergraphe Hc et les colonnes de A aux hyper-arêtes de Hc.
2.3.2.2 Partitionnements à deux dimensions
Un partitionnement à deux dimensions, ou 2D, de la matrice A est également possible
en procédant de plusieurs façons que nous allons décrire ici. Dans un partitionnement
de matrice 2D, les colonnes comme les lignes de la matrice peuvent être découpées en
plusieurs parties, ce qui implique des communications dans les deux directions. Le partitionnement
2D d’une matrice creuse a l’avantage de généraliser le problème, ce qui peut
conduire à une solution plus intéressante avec un meilleur équilibrage ou moins de communications.
Nous évoquerons ici quatre modèles principaux de partitionnement à deux
dimensions.
Méthode coarse-grain. Pour partitionner une matrice A, la méthode coarse-grain a
la particularité d’essayer de conserver les rapprochements naturels des valeurs non nulles
de la matrice par colonnes et par lignes, tout comme le fait un partitionnement 1D, mais
en prenant en compte les deux dimensions. Il existe plusieurs types de méthodes dites
coarse-grain. On peut, par exemple, noter le partitionnement cartésien, très utilisé pour le
partitionnement de maillages cartésiens (nous reviendrons plus en détails dessus). Nous
pouvons également évoquer l’approche Mondriaan [120] qui consiste à successivement
partitionner en deux (ou bipartitionner) la matrice jusqu’à atteindre p parties. A chaque
bipartitionnement, les méthodes row-net et column-net sont essayées, et celle proposant
le meilleur partitionnement est conservée. Les deux hypergraphes Hr et Hc sont donc
partitionnés à chaque itération. Ainsi, cette méthode effectue des partitionnements 1D
mais qui peuvent être effectués dans les deux directions ce qui permet d’obtenir un
partitionnement 2D et un plus grand nombre de solutions potentielles.40 Chapitre 2. Etat de l’art
Méthode fine-grain. À l’inverse de la méthode coarse-grain, qui se base sur l’unité des
lignes et des colonnes de la matrice, la méthode fine-grain [30] se propose de partitionner
chaque valeur non-nulle de façon indépendante dans p partitions. Pour cela un nouveau
type d’hypergraphe, noté Hf , est construit à partir de la matrice A. Chaque sommet
de cet hypergraphe représente non plus les lignes ou les colonnes de la matrice, mais
chaque élément non nul de la matrice. Deux types d’hyper-arêtes sont alors représentées.
Une hyper-arête ligne i contient l’ensemble des sommets correspondants aux valeurs non
nulles de la ligne i de A. Une hyper-arête colonne j, quant à elle, contient l’ensemble des
sommets correspondants aux valeurs non nulles de la colonne j de A. Le nombre total de
sommets dans l’hypergraphe Hf est égal au nombre de valeurs non nulles, et le nombre
d’hyper-arêtes est au plus égal à m + n. Une fois cet hypergraphe construit, il peut être
partitionné en p sous-ensembles de sommets en suivant la contrainte d’équilibrage (2.15)
et en minimisant la métrique de coût (2.16).
Méthode hybride. La méthode hybride [18] reprend le principe de l’approche Mondriaan
par bipartitionnements successifs, mais en ajoutant aux partitionnements des hypergraphes
Hr et Hc le partitionnement de l’hypergraphe Hf de la méthode fine-grain.
Méthode medium-grain. La méthode medium-grain a récemment été proposée dans
les travaux de Pelt et Al [101]. Cette méthode sépare tout d’abord la matrice A en deux
matrices Ac
et Ar
. La valeur ai,j est ainsi assignée à la matrice Ar
si le nombre de
valeurs non nulles dans la ligne i est plus grand que dans la colonne j, et à Ac dans le
cas contraire. La méthode crée alors une matrice :
B =
In (Ar
)
T
Ac
Im
, (2.18)
où Im est la matrice identité de taille m × m, et In la matrice identité de taille n × n. La
méthode de partitionnement 1D row-net est ensuite utilisée pour partitionner la matrice
B. La matrice Ar
étant transposée dans B, il s’agit là encore d’un partitionnement à
deux dimensions, où la dimension de partitionnement, pour chaque valeur, est choisie en
fonction de son attribution dans Ar ou Ac
.
2.3.3 Cas particulier du partitionnement de maillages
Cette thèse traite de solutions de parallélisme implicite pour le cas des simulations
scientifiques dont la résolution est basée sur des maillages. Nous allons, dans cette partie,
étudier le cas spécifique, et pratique, du partitionnement de maillages pour les applications
parallèles. Nous allons, tout d’abord étudier l’état de l’art pour le cas des maillages
réguliers à deux dimensions, puis nous traiterons le cas des maillages non-structurés.
2.3.3.1 Maillages à deux-dimensions réguliers
Comme nous l’avons vu, un maillage régulier à deux dimensions peut être de deux
types. Soit un maillage cartésien, soit un maillage curvilinéaire, mais qui peut alors être2.3. Distribution de données 41
ramené à un maillage cartésien. Un maillage cartésien est bien souvent représenté, dans
les esprits, comme une matrice. Par conséquent un partitionnement fine-grain pourrait,
par exemple, être envisagé pour partitionner les éléments non nuls de la matrice (tous
les éléments dans le cas d’une matrice dense). Un maillage cartésien étant dense, des mé-
thodes plus directes, et plus simples à mettre en œuvre, permettent d’obtenir rapidement
des partitionnements équilibrés et contenant peu de communications.
Notons par exemple le partitionnement rectiligne [97], qui est obtenu en partitionnant
tout d’abord les lignes du maillage en P parties, puis les colonnes en Q parties, tel que
p = P Q. On peut ensuite assigner chaque combinaison obtenue à chaque processeur. Une
variante de ce partitionnement est d’utiliser la même technique mais suivant une unique
dimension. On pourra aussi appeler le partitionnement rectiligne 2D, le partitionnement
par blocs, et le partitionnement rectiligne 1D, le partitionnement par blocs-lignes.
Dans certains cas, un maillage cartésien peut être non-uniforme. C’est le cas des
maillages dits adaptatifs. Ce type de maillage peut être intéressant pour résoudre des
EDP, puisqu’il permet d’adapter le maillage avec plus ou moins de précision (de points)
suivant les zones d’intérêt du domaine. Dans ce cas, il peut être à la fois plus compliqué
d’équilibrer le partitionnement, mais aussi de minimiser les communications. Les
travaux de Berger et Al [13] ont introduit en 1987 le partitionnement par bissection ré-
cursive orthogonale (ORB) pour ce type de maillages. La figure 2.10 représente les trois
partitionnements de maillages 2D introduits ici.
Figure 2.10 – De gauche à droite : partitionnement en blocs, en blocs-lignes et bissection
récursive orthogonale
2.3.3.2 Maillages non-structurés
Nous venons de voir que le partitionnement des maillages réguliers est un cas de
partitionnement relativement simple et pour lequel un grand nombre de possibilités de
résolution est disponible. Certains maillages sont eux beaucoup plus compliqués à partitionner
de par leur structure irrégulière. La méthode des éléments finis, que nous avons
décrite dans la partie 2.2.4.3, mène dans la plupart des cas à la création d’un maillage
non-structuré qui permet de représenter avec fidélité et avec plus ou moins de précision
la surface ou le volume d’un objet. La plupart du temps les cellules de ces maillages représentent
des triangles (2D) ou des tétraèdres (3D), ce qui permet, suivant la taille des
mailles, de pouvoir représenter très précisément les surfaces ou les volumes. Une maille42 Chapitre 2. Etat de l’art
peut avoir une taille quelconque et peut être un triangle de forme quelconque dans l’espace.
Le voisinage y est donc régulier, dans le sens où toutes les cellules ont par exemple
trois cellules voisines (dans le cas de triangles), mais les structures de données permettant
de représenter un maillage non structuré sont elles plus complexes et plus lourdes que
dans un maillage cartésien. Par conséquent, là où un maillage cartésien peut facilement
être identifié à une matrice, le maillage non-structuré est lui plus facilement représenté
par un graphe. Le problème de partitionnement d’un maillage non-structuré repose donc
sur le fait de trouver la bonne représentation du maillage en graphe pour ensuite pouvoir
le partitionner. Quatre représentations sont très souvent utilisées dans la littérature :
— La première, et la plus simple, est de considérer chaque point du maillage comme
un sommet d’un graphe, et chaque arête d’une face du maillage comme une arête
du graphe. Cette représentation est appelée le graphe nodal du maillage [122].
— La deuxième représentation, appelée le graphe dual du maillage [46, 106], associe
chaque cellule du maillage à un sommet du graphe. Deux sommets du graphe
sont reliés par une arête si deux cellules du maillage ont un côté, ou une face, en
commun.
— La troisième représentation combine le graphe nodal et le graphe dual afin d’obtenir
une représentation plus précise sur le maillage [122].
— Enfin, le graphe dual-diagonal représente chaque cellule du maillage par un sommet,
et deux sommets sont reliés par une arête si les cellules ont un point en
commun dans le maillage. Notons que cette représentation peut elle aussi être
combinée au graphe dual pour représenter avec plus de précision le maillage.
La figure 2.11 illustre le graphe nodal, le graphe dual et le graphe dual-diagonal d’un
maillage non structuré 2D. Une fois que la représentation du maillage par un graphe
Figure 2.11 – De gauche à droite et de haut en bas : le maillage non structuré 2D, son graphe
nodal, son graphe dual et son graphe dual-diagonal.2.3. Distribution de données 43
est choisie, les partitionneurs de graphes peuvent être utilisés, comme par exemple
Jostle [122], Metis [46, 106] et Scotch [100, 106].
Dans les travaux de Zhou et Al [131], le partitionnement d’hypergraphe est utilisé pour
partitionner un maillage non-structuré 3D contenant 1.07 milliard de cellules, 163840
processeurs. Le maillage y est représenté par un hypergraphe dans lequel chaque sommet
est associé à une cellule du maillage, et chaque hyper-arête correspond à une cellule et aux
cellules partageant une face avec celle-ci. Le partitionneur Zoltan [27] est ensuite utilisé.
Il est donc également possible d’utiliser le modèle de partitionnement d’hypergraphes
pour partitionner un maillage non-structuré et représenter plus fidèlement le volume de
communications.
2.3.4 Partitionnements particuliers
2.3.4.1 Méthodes à contraintes et objectifs multiples
Considérons un problème de partitionnement d’hypergraphe, ou de graphe, défini
comme dans les parties précédentes. On peut alors appeler la contrainte d’équilibrage
de l’équation (2.15) la contrainte du partitionnement, et la minimisation des métriques
(λ − 1) de l’équation (2.16), ou edge-cut, l’objectif du partitionnement. Un partitionnement
à contraintes multiples [9, 108] consiste alors à appliquer un tableau de poids à
chaque sommet de l’hypergraphe ou du graphe au lieu d’un simple poids. Ce tableau
représente les multiples contraintes d’équilibrage à respecter lors du partitionnement. De
même un partitionnement à objectifs multiples [58] appliquera un ensemble de coûts de
communications à une hyper-arête de l’hypergraphe, ou à une arête du graphe.
Pour effectuer un partitionnement à contraintes multiples, Aykanat et Al [108] ont
modifié l’algorithme multilevel dans ses trois phases. En effet, les phases de réduction,
de bi-partitionnement et de raffinement ont été modifiées pour tenir compte de plusieurs
contraintes de partitionnement. Dans les travaux de Schloegel et Al [58], l’algorithme
de partitionnement pour objectifs multiples s’effectue en trois phases. Tout d’abord un
algorithme de partitionnement k-way du partitionneur METIS [77] est appliqué pour
chacun des objectifs séparément. Un nouveau poids est ensuite attribué à chaque arête
du graphe initial. Ce poids est calculé comme une fonction des différents poids de l’arête
(objectifs multiples), du meilleur résultat de la métrique edge-cut obtenu dans la première
phase, et du vecteur de préférence des objectifs précisé par l’utilisateur. Enfin, un dernier
partitionnement k-way est opéré sur le graphe avec les nouvelles pondérations d’arêtes.
2.3.4.2 Méthode pour les calculs à phases
Certains calculs scientifiques ou simulations ont la particularité d’être organisés en
plusieurs phases. Cette organisation peut être de plusieurs types dans une simulation.
Tout d’abord, il est possible qu’il s’agisse de différentes phases de calcul, mais toutes
exécutées sur l’ensemble du maillage. Dans ce cas, les différentes phases peuvent avoir
un impact sur le type de communications et il est alors possible d’utiliser des partitionnements
à contraintes ou objectifs multiples. Il est également possible que les différentes44 Chapitre 2. Etat de l’art
phases du calcul soient exécutées sur des maillages différents, soit complètement distincts
les uns des autres, soit reliés entre eux par une composition de maillages (section 2.2.2.2).
Nous traitons plus en détails ce cas dans la section 5.5 de cette thèse. Enfin, il est également
possible que les différentes phases d’un calcul agissent sur différentes parties
d’un même maillage. Dans ce cas une contrainte d’ordonnancement apparaît à la fois
sur les calculs, mais aussi sur le maillage. Les travaux de Walshaw et Al [125] traitent
de ce type de calculs à phases. Dans ces travaux de partitionnement, les sommets du
graphe vont être classifiés afin de déterminer la phase qui les concerne. Le premier
sous-ensemble de sommets est alors partitionné, puis les sous-ensembles suivants seront
partitionnés à leur tour en tenant compte des partitionnements précédents, grâce à la
notion de point stationnaire introduite dans ces travaux. Notons que la méthode est
également capable de traiter des sommets qui appartiennent à plusieurs phases du calcul.
Le problème de partitionnement de graphes est un problème qui a largement été
étudié et dont quelques représentations ont étés présentées dans cette section. Nous utiliserons,
dans le chapitre 5 plus spécifiquement, certaines de ces notions afin de présenter
le problème de partitionnement de réseaux, et deux méthodes de résolution.
2.4 Le parallélisme implicite
Nous allons désormais aborder le cœur de cette thèse : le parallélisme implicite. Derrière
ce terme se cache le fait de vouloir apporter un accès facile, simplifié, voire transparent,
au calcul haute performance et au parallélisme à des utilisateurs non spécialistes, et
même non-informaticiens. En effet, si la plupart des scientifiques ont de plus en plus besoin
des machines parallèles pour obtenir des simulations intéressantes, ils ne savent pas
pour autant les utiliser à leur pleine capacité, soit par manque de temps et de ressources
humaines, soit par manque de connaissances sur les architectures matérielles utilisées. De
nombreux travaux sur le parallélisme implicite ont vu le jour presque simultanément avec
l’arrivée d’architectures parallèles, complexes à programmer. Ce domaine de recherche
est très actif et le sera probablement de plus en plus étant donné la complexité des architectures
parallèles actuelles et à venir (hiérarchie de mémoires complexes, systèmes
massivement multi-cœurs, systèmes hybrides etc.). Nous allons présenter, dans cet état de
l’art, un aperçu des solutions les plus utilisées et les plus reconnues du parallélisme implicite.
Pour cela, nous allons tout d’abord présenter des solutions permettant de classifier
les problèmes parallèles, nous évoquerons ensuite quelques-uns des nombreux langages
et des nombreuses bibliothèques de parallélisme partiellement implicites. Enfin nous entrerons
à proprement parlé dans les solutions de parallélisme implicite totale, que nous
essaierons de classer par niveau d’abstraction, le niveau d’abstraction le plus haut (qui ne
correspond pas nécessairement au meilleur niveau d’abstraction, et c’est l’une des discussions
de cette thèse) étant celui qui cache le plus de technicités et qui demande le moins
d’apprentissage à l’utilisateur. Ainsi nous évoquerons les bibliothèques de parallélisme
implicite générales, les solutions à patrons, puis pour terminer les solutions spécifiques
au domaine du calcul scientifique.2.4. Le parallélisme implicite 45
2.4.1 Classification de problèmes et aide à la parallélisation
Le niveau d’abstraction le plus bas du parallélisme est de considérer qu’il est préférable
de laisser les utilisateurs coder leurs propres programmes parallèles, mais de les aider dans
la conception de ces programmes. Les modèles de programmation tels que BSP [92, 119]
ou MPI [61], ainsi que le paradigme SPMD, décrits précédemment, sont des exemples de
solutions permettant de simplifier le parallélisme et proposent un niveau d’abstraction
plus haut que la programmation parallèle de base. En effet, pour certains types d’architectures
parallèles et pour certains types de problèmes, ces modèles de programmation
parallèle peuvent être utilisés relativement facilement. Il est d’ailleurs fréquent d’initier
les scientifiques à la programmation parallèle par ce type de modèles [17, 87].
Toutefois, lorsque les algorithmes deviennent plus compliqués à mettre en œuvre,
comme par exemple dans le cas de problèmes irréguliers, les modèles les plus simples
sont souvent limités, et une réflexion différente sur la conception du programme parallèle
est souvent nécessaire. Les travaux de Pingali et Al [103] proposent une classification
des algorithmes afin de mieux identifier la façon dont ils peuvent être parallélisés effi-
cacement. Il a ainsi été proposé The TAO of Parallelism in Algorithms, qui peut être
vu comme une abstraction des algorithmes. Cette abstraction permet, d’une part, d’extraire
les propriétés importantes pour la parallélisation du problème et, d’autre part, de
mettre de côté les propriétés n’entrant pas en compte dans les choix de parallélisation.
Dans l’analyse-TAO, la définition d’un algorithme est inspirée de l’aphorisme de Niklaus
Wirth [129] : Program = Algorithm + Data structure. L’abstraction proposée par
l’analyse-TAO est appelée operator formulation of algorithms. L’algorithme y est traduit
comme un graphe représentant les opérations effectuées sur des types de données abstraits,
noté graphe ADT. L’analyse-TAO se décompose en trois étapes. Tout d’abord la
définition de la topologie, qui représente la structure de données sur laquelle les calculs
sont effectués, puis les nœuds actifs, qui représentent les éléments à calculer dans une
opération donnée, et enfin les opérateurs, qui représentent les actions à effectuer sur les
nœuds actifs.
Une simulation scientifique pour laquelle des schémas numériques explicites sont appliqués
sur un maillage fixe (à l’inverse d’un maillage adaptatif) est définie comme suit,
dans l’analyse-TAO :
— Topologie : La topologie nécessaire dépend du type de maillage utilisé. Elle
peut être structurée, comme par exemple pour des maillages cartésiens, ou nonstructurée,
pour les maillages du même nom.
— Nœuds actifs : Dans l’analyse-TAO, l’algorithme d’une simulation scientifique est
appelé topology-driven. Cela signifie que l’ensemble des éléments du maillage sont
calculés à chaque itération de temps. De plus, si les schémas numériques à calculer
sont explicites, leurs calculs ne dépendent que de l’itération précédente et les
éléments du maillage peuvent donc être calculés de façon non-ordonnée.
— Opérateurs : Les opérations mises en place dans une simulation scientifique repré-
sentent les calculs des schémas numériques. Dans le cas d’un maillage non adaptatif,
ou fixé, la morphologie du maillage n’est pas modifiée au cours de l’algorithme.
L’analyse-TAO appelle ce type de simulation un calcul local (local computation).46 Chapitre 2. Etat de l’art
Notons que dans le cas d’une simulation utilisant des schémas numériques implicites,
l’algorithme est appelé data-driven, ce qui signifie que les calculs de certains éléments du
maillage peuvent rendre calculables d’autres éléments. Enfin, dans le cas d’un maillage
adaptatif, les opérateurs sont de type morph, ce qui signifie que le maillage peut être
modifié à chaque itération de temps de la simulation.
Ce premier niveau d’abstraction vise à simplifier la classification des algorithmes
parallèles et permet donc d’obtenir une première approche simplifiée du parallélisme
et du calcul haute performance. Toutefois, il ne résout pas le fait que l’utilisateur ne
connaît pas suffisamment les architectures et les détails techniques de la programmation
parallèle pour écrire des programmes parallèles. Ces modèles de programmation et ces
classifications sont, en revanche, très utilisés afin d’élaborer des solutions de parallélisme
implicite d’un niveau d’abstraction plus élevé.
2.4.2 Solutions partiellement implicites
Il existe un très grand nombre de bibliothèques et de langages permettant de simplifier
l’utilisation des machines parallèles, et donc d’écrire des programmes pour ces machines.
Toutefois, ces solutions proposent un parallélisme qui n’est que partiellement implicite,
laissant à l’utilisateur la charge de quelques notions parallèles, qui peuvent paraître, pour
certaines, simples sur de petits programmes, mais qui peuvent s’avérer très compliquées
pour des calculs eux-mêmes complexes. Les niveaux de parallélisme implicite proposés
sont très variés et nous allons évoquer ici quelques unes de ces solutions.
À un niveau d’abstraction relativement bas, on peut tout d’abord noter les langages
ou interfaces de programmations à base de directives. Citons deux de ces solutions, High
Performance Fortran [93] (HPF) et OpenMP [34]. Les langages de directives, et particulièrement
HPF ont été largement critiqués, et notamment accusés de rejeter certains
détails du parallélisme, potentiellement très techniques, sur l’utilisateur. En effet, HPF
demande, par exemple, à l’utilisateur de préciser les alignements des données entre elles,
ce qui garantit leur placement sur un même processeur. De même, HPF demande à l’utilisateur
de préciser des directives de distribution de données (en bloc, ou de façon cyclique
élément par élément, par exemple). OpenMP, de son côté, a réussi à devenir une réfé-
rence pour paralléliser facilement, mais pas nécessairement efficacement, des applications
sur architectures à mémoire partagée. Rappelons que si OpenMP est initialement un
modèle de programmation induit par et fait pour les architectures à mémoire partagée,
son implémentation peut être effectuée sur différents modèles d’exécution et notamment
pour des architectures à mémoire distribuée, en utilisant, par exemple, une architecture
DSM [79]. Bien que ses performances soient limitées, OpenMP est très utilisé dans les
calculs scientifiques. Les directives OpenMP sont, en effet, peu nombreuses et moins
techniques que celles proposées par HPF. Deux types de parallélisation sont possibles
en utilisant OpenMP. La plus simple, et la plus connue des scientifiques, est la méthode
dite fine-grain, ou à grain fin. Elle consiste en la parallélisation automatique de boucles
for. L’unique difficulté pour l’utilisateur est alors de détecter quelles variables sont locales
à la boucle et quelles variables doivent être partagées par les différents processus2.4. Le parallélisme implicite 47
créés automatiquement. Toutefois, il n’est pas rare que cette parallélisation de boucle,
très limitée, ne soit pas suffisante pour obtenir des performances intéressantes, et même
parfois acceptables, dans les calculs scientifiques. Le deuxième type de programmation
OpenMP est alors utilisé et s’appelle coarse-grain, ou à gros grain. Dans ce cas, on peut
noter deux types de solutions. Dans la première l’utilisateur a la charge de définir une
zone de code parallèle, en utilisant la directive #pragma omp parallel, où l’ensemble des
processus, créés automatiquement, exécuteront la même portion de code. L’utilisateur
devra également définir les variables locales et partagées, et on retrouve dans ce type de
parallélisation des problèmes de décomposition de domaine. Cette solution coarse-grain
peut donc s’apparenter au parallélisme de données. Le deuxième type de programmation
coarse-grain qui peut être envisagé est la définition par l’utilisateur de sections pouvant
directement être assignées à des processus différents. La directive est alors #pragma omp
parallel sections. Dans ce cas l’utilisateur doit identifier des tâches pouvant être effectuées
en parallèle par plusieurs processus, cette solution s’apparente donc au parallélisme de
tâches. L’utilisation des méthodes coarse-grain, permet souvent d’obtenir de meilleures
performances, toutefois le niveau d’abstraction proposé est plus bas, et le parallélisme
peu implicite, tout comme dans l’utilisation de HPF.
Les langages BSPLib [69] et Co-array Fortan [98] peuvent ensuite être évoqués. Là
encore, il ne s’agit pas d’un parallélisme implicite total, toutefois la programmation parallèle
y est simplifiée par un nombre de concepts limités et par l’utilisation d’un modèle
de programmation proche de BSP. Ces langages permettent notamment de simplifier
le paradigme à passage de messages, proposé par exemple par MPI [61]. BSPLib, par
exemple, ne demande à l’utilisateur que d’expliciter les envois de données et les synchronisations.
Le reste du travail est effectué directement par la bibliothèque grâce au modèle
de programmation BSP.
ZPL [32] est un langage parallèle basé sur la définition et l’utilisation de tableaux. Il
est, pour cette raison, notamment très adapté aux calculs matriciels. La distribution des
tableaux est effectuée automatiquement lors de l’exécution, et les programmes implémentés
suivent le paradigme SPMD. Si HPF, Co-array, BSPLib et ZPL, par exemple, sont des
langages proches du paradigme SPMD, ce qui signifie que chaque thread exécute le même
code sur des données différentes, les langages X10 [35] et Chapel [31] proposent, quant à
eux, un modèle plus général permettant de contrôler un ensemble d’opérations concurrentes.
Notons enfin que les langages Co-array et X10, parmi d’autres langages, utilisent
le modèle de programmation parallèle PGAS (Partitioned Global Adress Space) [6], déjà
évoqué dans la partie 2.1.2.2.
2.4.3 Solutions générales de parallélisme implicite
Le niveau d’abstraction suivant permet, lui, de cacher de façon beaucoup plus prononcée
le parallélisme aux utilisateurs. Nous évoquons ici les solutions de parallélisme
implicite, que l’on qualifie de générales, à l’inverse des solutions spécifiques que nous évoquerons
par la suite. Il s’agit à proprement parlé, de solutions de parallélisme implicite
totales. Afin de comprendre cette classe de solutions, nous allons prendre l’exemple de
la librairie standard template [95] du C++ (STL). Cette bibliothèque, très générale, per-48 Chapitre 2. Etat de l’art
met d’instancier et d’utiliser des conteneurs, comme par exemple des vecteurs, des listes,
des dictionnaires etc., et même de les imbriquer entre eux. Un ensemble d’algorithmes
peuvent ensuite être appliqués sur ces conteneurs, mais il est également possible d’écrire
ses propres programmes au moyen d’un outil principal : l’itérateur. L’itérateur permet,
en effet, de se déplacer dans un conteneur, mais aussi d’accéder aux valeurs qui y sont
associées. La STL permet d’écrire de façon simplifiée des programmes en C++. En un
sens donc, la STL est une solution de “conteneurs implicites”, qui permet de cacher la
complexité de gestion de conteneurs en C++ et de faciliter leur utilisation. Nous évoquons,
par le terme bibliothèques de parallélisme implicite générales, des bibliothèques
équivalentes à la STL mais qui permettent d’écrire des programmes parallèles. Il s’agit
donc de solutions permettant de mettre en place le paradigme de parallélisme de données,
ou SPMD, par la parallélisation des conteneurs et de leur manipulation. Dans ce
cas, les programmes écrits sont très généraux et touchent potentiellement un très grand
nombre de domaines scientifiques. Certaines de ces bibliothèques sont plus spécifiquement
décrites ici.
Bibliothèques sur conteneurs généraux. STAPL [25] signifie Standard Template
Adaptative Parallel Library et il s’agit d’une version parallèle de la STL, que nous venons
de décrire. Cette bibliothèque emprunte donc un certain nombre de concepts de
la STL, et son but est d’offrir autant, ou plus, de possibilités de codage que la STL,
tout en produisant des programmes parallèles. STAPL est composée de deux composants
principaux, le premier est le concept de pContainer qui représente une structure de données
distribuée représentant un conteneur distribué (tableau [113], liste [115], etc.). Le
deuxième concept, pAlgorithm, représente, quant à lui, un algorithme à appliquer sur un
pContainer. Il est possible dans STAPL, comme dans la STL, d’imbriquer des pContainers
et donc d’imbriquer également des appels à des pAlgorithms. Le niveau d’abstraction
proposé par STAPL est illustré par le concept de pView [24], qui représente une géné-
ralisation du concept d’itérateur. Le concept pView permet le parallélisme via un accès,
d’ordre inconnu, à l’ensemble des éléments d’un conteneur. Enfin, le STAPL parallel
container framework [114] permet d’écrire de nouveaux pContainers de façon simplifiée.
Notons également la bibliothèque PSTL [64], dont les buts sont proches de STAPL. PSTL
est une version parallèle de la STL, mais cette bibliothèque cherche à rester compatible
avec la STL là où STAPL propose de nouveaux conteneurs qui n’existent pas dans la
STL comme les matrices (pMatrix ) et les graphes (pGraph). La bibliothèque Threading
Building Blocks (TBB) [105] implémente, elle aussi, certains concepts équivalents à la
bibliothèque STAPL mais ne vise initialement que les architectures à mémoire partagée
(bien qu’une implémentation utilisant une architecture DSM puisse là encore être envisagée).
STAPL, de son côté, fonctionne de base à la fois pour les architectures à mémoire
partagée et distribuée. Enfin, Thrust [70] est une bibliothèque proposant elle aussi un
équivalent de la STL en parallèle pour des architectures GPU et hybrides CPU-GPU.
Bibliothèques sur graphes. Si les bibliothèques STAPL, PSTL et TBB se veulent
généralistes pour tout type de conteneurs, certaines bibliothèques, elles aussi basées sur2.4. Le parallélisme implicite 49
des conteneurs et des algorithmes, se concentrent sur les graphes, ce qui représente un
problème difficile à gérer en lui-même. Parallel Boost Graph Library (PBGL) [62] est une
bibliothèque parallèle générale sur les graphes. PBGL est une version parallélisée de BGL
(Boost Graph Library) [1], et reste entièrement compatible avec cette version séquentielle.
BGL et PBGL sont des bibliothèques implémentées dans l’ensemble de bibliothèques
Boost [3]. Elles en héritent donc les forts concepts de généricité et d’efficacité. BGL,
et donc PBGL, sont des bibliothèques C++ génériques visant à pouvoir exprimer un
maximum de problèmes, tout en proposant une implémentation efficace. Leur implé-
mentation est, pour cela, basée sur les concepts avancés de méta-programmation [3] et
de spécialisation de template [5]. Il en résulte, pour des scientifiques non-informaticiens,
une programmation techniquement difficile à comprendre, d’autant plus que certains
paramètres de spécialisation, permettant de rendre la solution plus efficace, sont loin
des préoccupations des scientifiques, comme par exemple des informations sur le type de
représentation du graphe souhaité, ou sur la distribution à effectuer pour le parallélisme.
En souhaitant être une bibliothèque générale à tous les problèmes de graphes, elle restreint
son utilisation à des utilisateurs avancés du C++ (bien qu’aucun code parallèle ne
soit demandé à l’utilisateur). La bibliothèque CGMGraph [33] implémente, tout comme
PBGL, un certain nombre d’algorithmes sur les graphes, comme par exemple les composantes
connexes, les arbres couvrants etc. Toutefois, le paradigme de programmation des
deux méthodes est différent. CGMGraph est une bibliothèque orientée objet alors que
PBGL est orientée programmation générique.
Il peut sembler que ce niveau d’abstraction est idéal. En effet, étant très général, il
touche l’intégralité du monde scientifique et permet d’écrire des programmes parallèles
en cachant potentiellement intégralement les détails du parallélisme. Cependant, de par
la généralité de ces solutions, il est difficile de proposer des optimisations spécifiques à un
domaine. Ces solutions peuvent être performantes mais ne peuvent être à la hauteur d’un
parallélisme manuel et optimisé pour un problème spécifique de simulation scientifique.
De plus, le souhait de généricité de ces solutions, et leurs paramétrages parfois complexes,
peut être à l’origine de nouvelles difficultés pour l’utilisateur. Une généricité trop faible,
à l’inverse, peut nuire aux performances de la solution.
2.4.4 Solutions à patrons
Les bibliothèques de parallélisme implicite générales, comme nous venons de le voir,
cherchent à cacher le parallélisme par le biais de conteneurs, d’algorithmes et d’itérateurs
(tableaux 1D, 2D, graphes etc.). Nous allons maintenant aborder des solutions proposant
un niveau d’absrtaction que nous considérons plus haut puisque davantage de détails
sont cachés à l’utilisateur. Ces solutions sont, elles aussi, basées sur des structures de
données implicitement distribuées, mais proposent, de plus, d’identifier les opérations
effectuées dans un programme comme un ensemble de patrons de programmation (ou
patterns). Ces patterns seront ensuite responsables de la parallélisation implicite des
opérations séquentielles du code de l’utilisateur. Les patrons proposent un haut niveau
d’abstraction. Par exemple, ce type de solutions cache généralement la navigation dans50 Chapitre 2. Etat de l’art
les structures de données, la notion d’itérateur n’est alors plus nécessaire. Nous allons
décrire ici deux grandes familles de solutions à patrons. Tout d’abord, le domaine des
squelettes algorithmiques sera décrit et quelques unes des nombreuses bibliothèques de
ce domaine seront étudiées. Puis, quelques autres solutions à patrons seront décrites.
2.4.4.1 Squelettes algorithmiques.
Les squelettes algorithmiques parallèles ont été introduits en 1988 par Muray
Cole [40]. Ils représentent des patrons de parallélisation fonctionnels, en d’autres termes
des abstractions de schémas de parallélisme, que l’on retrouve de façon récurrente dans
les applications parallèles. Ainsi, en théorie, n’importe quel programme parallèle peut
s’exprimer comme une suite ou une imbrication de squelettes algorithmiques fonctionnels.
Aucune norme n’a été définie pour écrire des squelettes, ni même aucun consensus.
Toutefois, le travail de Cole [39] indique quelques règles de conception pour produire
des squelettes adaptés et donc plus utilisés. Un squelette a idéalement un champ d’application
le plus large possible, afin de pouvoir être utilisé dans un grand nombre de
cas, sa sémantique doit être compréhensible des utilisateurs, et enfin il ne doit pas être
redondant avec d’autres squelettes.
Les squelettes algorithmiques se découpent en trois grandes classes, les squelettes pour
le parallélisme de données (map, reduce, zip etc.), les squelettes pour le parallélisme de
tâches (farm, pipeline etc.), et enfin les squelettes dits de résolution (divide and conquer,
branch and bound). Des détails sur l’ensemble de ces squelettes peuvent être trouvés
dans la thèse de Legaux [84]. Nous n’allons ici décrire que quelques squelettes pour le
parallélisme de données, auxquels nous feront référence dans cette thèse. Nous pouvons,
tout d’abord, noter les trois squelettes de base les plus connus et les plus simples à
comprendre. Le premier s’appelle map et permet d’appliquer une fonction locale à un
ensemble de données d’entrée en parallèle. Une fonction locale est alors une fonction
dont le calcul ne dépend que d’un élément d’entrée sans aucune dépendance avec les
autres éléments. Le squelette peut alors distribuer la structure de données et appliquer
la fonction à chacun des éléments séparément. Le squelette prend alors un vecteur de
données d’entrée [x1, x2, . . . , xn], retourne un vecteur de données de sortie [y1, y2, . . . , yn]
et applique une fonction f telle que
map f [x1, x2, . . . , xn] = [f(x1), f(x2), . . . , f(xn)] = [y1, y2, . . . , yn].
Le second squelette de base est le squelette zip qui est une extension de map pour
deux vecteurs d’entrée. Il distribue deux vecteurs de données d’entrée [x1, x2, . . . , xn] et
[x
0
1
, x0
2
, . . . , x0
n
], de même taille, et retourne un nouvel vecteur de sortie [y1, y2, . . . , yn] en
appliquant une fonction f telle que
zip f ([x1, x2, . . . , xn], [x
0
1
, x0
2
, . . . , x0
n
]) = [f(x1, x0
1
), f(x2, x0
2
), . . . , f(xn, x0
n
)]
= [y1, y2, . . . , yn].
Enfin, le squelette reduce permet de réduire un vecteur de données d’entrée [x1, x2, . . . , xn]
en un unique élément e suite à l’appel d’une opération de réduction, que nous noterons2.4. Le parallélisme implicite 51
⊕, telle que
reduce ⊕ [x1, x2, . . . , xn] = x1 ⊕ x2 ⊕ . . . ⊕ xn = e.
Les squelettes map et zip sont des squelettes qui ne peuvent appliquer que des calculs
locaux, de par leur construction. En d’autres termes, il n’est pas possible avec uniquement
ces squelettes d’effectuer des calculs de type stencil. La fonction décrite par l’utilisateur
ne décrit, en effet, que l’opération à effectuer sur un élément de l’ensemble de départ. Un
calcul stencil dépendant d’un certain voisinage de l’élément courant, il est nécessaire de
faire appel au squelette shift. Ce squelette va prendre un vecteur d’entrée [x1, x2, . . . , xn],
et retourner un vecteur de sortie [y1, y2, . . . , yn] égal à l’ensemble d’entrée décalé (le
décalage appliqué étant précisé par l’utilisateur). Par exemple, pour un décalage de un
élément vers la droite nous obtiendront
[y1, y2, . . . , yn] = [×, x1, x2, . . . , xn−1].
De cette manière en accédant au deuxième élément des ensembles [x1, x2, . . . , xn] et
[×, x1, x2, . . . , xn−1], il est possible de faire des opérations sur x2 et x1 en même temps.
Avec ces quatre squelettes de base, on peut très facilement observer les limites de l’approche
par squelettes pour des simulations scientifiques complexes. Pour cette raison, des
squelettes de type stencil sont apparus, notamment dans la bibliothèque SkelCL [21,111].
Cette bibliothèque implémente des squelettes de base pour les GPU et multi-GPU en utilisant
le langage OpenCL [112]. Il s’agit donc également d’un code portable. Elle propose
un squelette de stencil simple nommé MapOverlap qui permet de décrire une opération
de stencil simple, et un squelette de stencil plus complexe, nommé Stencil permettant
notamment de décrire des opérations stencil itératives. Afin de pouvoir effectuer les calculs
de type stencil, une distribution contenant des éléments fantômes est mise en place
dans la bibliothèque de squelettes, et n’existe pas dans les autres solutions. De plus, les
échanges à effectuer entre les processeurs sont automatiquement détectés par les arguments
utilisés dans le stencil. Notons que SkelCL ne fonctionne que pour les structures
de données de type vecteur ou matrices.
Parmi les bibliothèques de squelettes permettant de faire du parallélisme de données
et écrites en C++, on peut noter OSL [72], SkeTo [76], SkePu [52], et Muesli [37], chacune
ayant ses propres particularités. SkeTo, par exemple, est la seule bibliothèque proposant
une solution de squelettes sur les arbres [90]. SkePu propose une implémentation GPU,
et Muesli une implémentation hybride MPI/OpenMP des squelettes algorithmiques de
base. Enfin, OSL propose des optimisations à base de méta-programmation C++ [71,84].
Bien que les squelettes algorithmiques parallèles proposent un niveau d’abstraction
intéressant, ce domaine est très peu utilisé pour des simulations scientifiques complexes.
A notre connaissance, aucune simulation complexe n’a été écrite avec des squelettes algorithmiques,
et leur utilisation se limite à des cas “jouet” comme la résolution de l’équation
de la chaleur. Avec l’arrivée de nouveaux squelettes spécifiquement écrits pour le calcul
stencil [21], l’utilisation des squelettes algorithmiques est enclin à se développer dans
cette discipline. Toutefois, dans le domaine des mathématiques appliquées, les langages
de programmation enseignés aux scientifiques sont très souvent des langages impératifs
comme Fortran, C et C++. L’utilisation de langages fonctionnels, et donc de squelettes52 Chapitre 2. Etat de l’art
algorithmiques parallèles, demande un effort d’apprentissage supplémentaire qui pourrait
éloigner certains numériciens. Toutefois, notons qu’un langage fonctionnel peut être appris
très rapidement et très facilement par les mathématiciens car il s’agit d’un langage
plus proche des mathématiques.
2.4.4.2 D’autres solutions à patrons.
L’entreprise Google est à l’origine de la démocratisation de l’utilisation du modèle
MapReduce [47]. Dans ce modèle il est considéré que tout calcul peut être décomposé en
une série d’association du squelette map et du squelette reduce. Cette solution a permis
de démocratiser les squelettes algorithmiques, grâce à l’association intelligente de deux
concepts simples, dont la mise en œuvre est facilitée par un certain nombre d’outils.
Ainsi, par exemple, le framework Hadoop [127], très connnu et très utilisé, propose un
système de fichiers distribués et une implémentation de MapReduce. Il est alors possible
de faciliter la création d’applications distribuées ainsi que leur déploiement sur des milliers
de processeurs. Enfin, ce type de systèmes embarque généralement la gestion des pannes
du programme ou du matériel, ce qui augmente encore l’intérêt des scientifiques, dont les
simulations peuvent être très longues et coûteuses.
Google est également à l’origine d’un modèle de parallélisme simple pour les traitements
parallèles sur les graphes dirigés, Pregel [88]. Dans l’état de l’art de ce travail,
Pregel est comparé à PBGL et CGMGraph (décrits précédemment), et le principal
argument avancé pour préférer son utilisation est la tolérance aux pannes. Toutefois, le
type d’approche est très différent. En effet, Pregel conserve une idée proche des squelettes
algorithmiques et se base également sur le modèle BSP pour structurer de façon générale
des opérations sur des graphes dirigés. Dans Pregel, le graphe est tout d’abord distribué
sur les différents processeurs. Un calcul dans Pregel est ensuite composé de plusieurs
itérations, que l’on peut comparer à des super-étapes du modèle BSP. Dans chacune
de ces étapes, le framework Pregel appelle une fonction utilisateur qu’il applique sur
chaque nœud du graphe distribué. La fonction spécifie le comportement d’un unique
nœud général v pour une étape S. Cette fonction peut recevoir des messages envoyés à v
à l’étape S − 1, et peut envoyer des messages à d’autres nœuds qui seront reçus à l’étape
S + 1. On retrouve alors la notion de fonction utilisateur de MapReduce, ou de tout
autre squelette, et l’on retrouve également l’application de cette fonction sur chacun des
nœuds, tout comme dans les squelettes algorithmiques. Toutefois le modèle Pregel est
une solution plus générale que les squelettes algorithmiques habituels et ne représente
pas un patron de parallélisation unique. La fonction utilisateur peut, en effet, décrire des
problèmes très variés et offre une plus grande liberté de codage que dans l’utilisation des
squelettes algorithmiques. Il est important de noter que les algorithmes sur les graphes
peuvent être exprimés comme des chaînes d’appels à MapReduce [38, 75]. Toutefois,
le modèle Pregel propose de meilleures performances. En effet, il conserve la même
distribution de graphe d’une étape à l’autre du calcul, et utilise uniquement l’envoi et la
réception de messages pour obtenir les informations d’autres processeurs. L’utilisation
de MapReduce implique, quant à elle, tout d’abord (1) une distribution initiale des
données, puis (2) l’application du map, puis pour terminer (3) des communications pour2.4. Le parallélisme implicite 53
l’application du reduce, ce qui revient à communiquer toutes les données résultantes du
map à chaque appel d’un mapreduce. Giraph [7] est une alternative à Pregel et utilise
les même concepts.
Là encore il s’agit de solutions de parallélisme implicite très intéressantes et avec un
certain nombre d’avantages. L’utilisation de ce type de solutions semble, tout d’abord,
simplifier encore davantage la création de programmes parallèles, et la classification des
différentes opérations d’un programme en patrons ne semble pas extrêmement difficile
à élaborer. De plus, ces solutions utilisent deux types de spécificités pour mettre au
point des optimisations : les structure de données distribuées, et les patrons utilisés. Les
possibilités de performances paraissent donc plus importantes que dans les solutions dites
générales. Cependant, un certain nombre de problèmes peuvent être notés avec ce type
de solution. Tout d’abord, et comme nous l’avons décrit, chaque opération décrite à l’aide
d’un patron, ou d’un squelette, est en fait une opération simple. À l’exception de Pregel,
ces solutions sont très proches de la programmation fonctionnelle. Ainsi, si des calculs
complexes doivent être mis en œuvre, un grand nombre d’appels imbriqués sera nécessaire,
ce qui peut complexifier l’écriture, la lecture et la compréhension des programmes. Dans
cette solution, de nouveau, il semble possible que la difficulté de programmation parallèle
soit déportée vers l’utilisation de nouveaux concepts, et notamment vers les difficultés de
la programmation fonctionnelle, non connue de la plupart des scientifiques.
2.4.5 Solutions spécifiques à un domaine
Nous avons donc vu que les solutions générales de parallélisme implicite sont uniquement
spécifiques aux types de conteneurs utilisés, ce qui peut limiter les optimisations du
programme parallèle. Les solutions à patrons de parallélisme sont, quant à elles, spéci-
fiques aux types de conteneurs mais aussi aux types de patrons utilisés, ce qui augmente
les possibilités d’optimisations pour le problème posé. Dans cette dernière partie, nous
allons voir le niveau d’abstraction et de spécificité le plus haut qui est proposé dans les solutions
de parallélisme implicite pour le calcul scientifique. Dans ce cas, la solution, qu’elle
soit une bibliothèque, un framework ou un langage dédié (noté DSL), est spécifiquement
implémentée pour un problème spécifique et propose des optimisations de performances
dues à cette spécificité, qu’on ne pourrait donc pas retrouver dans les solutions plus géné-
rales. La définition de “spécifique” peut être très variée. Par exemple, STAPL peut être vu
comme un langage dédié à la programmation par conteneurs parallèles. Toutefois, nous
rattacherons ici, et dans le reste de cette thèse, une solution dite spécifique (de type DSL,
bibliothèque etc.) à une solution spécifique au calcul scientifique. Le domaine du calcul
scientifique reste un domaine très vaste, même si il est beaucoup plus spécifique que les
domaines traités par STAPL, PBGL ou les squelettes algorithmiques. Pour cette raison,
de nombreuses solutions spécifiques ont été mises au point pour divers sous-problèmes
du calcul scientifique. Certaines solutions sont plus spécifiques que d’autres, tout le problème
étant de trouver le niveau d’abstraction idéal pour l’utilisation qui sera faite du
langage.
Parmi les solutions parallèles spécifiques aux applications scientifiques, on peut tout54 Chapitre 2. Etat de l’art
d’abord noter ScaLAPACK [19] qui est une bibliothèque haute performance pour les
calculs de l’algèbre linéaire, implémentée pour les architectures parallèles à mémoire
distribuée. On peut ensuite noter FFTW [55], pour le calcul parallèle de la transformée de
Fourier discrète, et donc notamment pour des problèmes de traitement du signal. Ce DSL
est implémenté pour des architectures à mémoire partagée et distribuée. SPIRAL [104]
est, quant à lui, un DSL plus récent permettant, de façon plus générale que FFTW,
d’effectuer des traitements du signal numérique.
Dans cette thèse, nous nous intéressons plus particulièrement à la résolution des EDP
par des méthodes numériques basées sur des maillages, et donnant lieu à des schémas
numériques explicites. Nous nous intéressons donc aux problèmes stencils pour tout type
de maillages, et nous allons évoquer avec plus d’attention, dans le reste de cette section,
les DSL, bibliothèques et frameworks spécifiquement développés pour ce type de calculs
que l’on peut appeler plus généralement des DSS pour Domain Specific Solutions.
2.4.5.1 EDP et EDO sur maillages structurés
Commençons par évoquer les solutions de parallélisme implicite spécifiques au calcul
de type stencil sur les maillages structurés. Il en existe en très grand nombre, et il ne
s’agit pas ici d’une liste exhaustive de ces solutions mais des quelques travaux qui paraissent
proches des solutions présentées dans cette thèse. Notons tout d’abord l’une des
solutions les plus utilisées et les plus connues du monde de la simulation scientifiques,
PETSc [10–12]. PETSc est une solution très vaste qui permet d’écrire des applications
scientifiques, modélisées par des EDP, en parallèle. Cette solution est composée d’outils
permettant d’effectuer des opérations sur des vecteurs et des matrices, de solveurs
d’équations linéaires et non linéaires, mais également d’outils graphiques permettant la
visualisation des résultats de l’application. PETSc est implémenté pour les architectures à
mémoire partagée CPU et GPU grâce aux modèles pthreads, CUDA et OpenCL, pour les
architectures à mémoire distribuée, grâce à l’utilisation de MPI, et pour les architectures
à mémoire hybrides aux travers des associations MPI-pthreads et MPI-GPU. PETSc est
basé sur un ensemble de structures de données distribuées et sur un ensemble de fonctions
ou routines spécialisées pour ce type de traitements. PETSc est donc identifiable à une
bibliothèque sur conteneurs, tout comme STAPL ou PSTL, mais dont les interfaces et
les algorithmes sont spécifiques au traitement des EDP. Nous pouvons également noter
la bibliothèque spécifique Trilinos [68] qui est très proche de PETSc.
Nous pouvons également noter des solutions spécifiques plus récentes, comme par
exemple le framework développé à Berkeley permettant de générer automatiquement un
code parallèle, adapté à l’architecture à mémoire partagée et au matériel utilisé (dit
auto-tuned), uniquement à l’aide de l’expression d’un stencil en Fortran [74]. Dans cette
même famille de framework auto-tuned, on peut également noter PATUS [36] qui génère
du code parallèle CPU et GPU à partir de l’expression d’un stencil et d’une stratégie de
parallélisation. PATUS évalue ensuite la meilleure parallélisation pour le matériel utilisé.
Évoquons également Panorama [86] qui utilise des techniques particulières pour minimiser
les défauts de cache, et Pochoir [116] qui permet la définition de stencils à n dimensions
en C++. Physis [89] ne propose pas de solution auto-tuned mais permet lui aussi de2.4. Le parallélisme implicite 55
définir une expression stencil à l’aide d’un DSL, et d’en produire automatiquement des
applications parallèles MPI et CUDA.
2.4.5.2 EDP sur maillages non-structurés
De nombreuses solutions de parallélisme implicte, spécifiques aux calculs de type
stencil, sont donc disponibles pour les maillages structurés. Lorsque le problème des
maillages non-structurés est abordé, les outils se font plus rares, mais il en existe également.
Nous pouvons, tout d’abord, évoquer le plus ancien d’entre eux, OP2 [59, 60, 94].
OP2, développé à l’université d’Oxford, est une révision du framework OPlus [23], initié
en 1993, qui permettait d’écrire des applications basées sur un maillage non-structuré
et sur la méthode des éléments finis. OPlus a notamment été utilisé pour paralléliser, en
1995, une simulation d’écoulement des fluides non visqueux sur un maillage complexe
représentant un avion [45]. OPlus était implémenté pour les architectures à mémoire
distribuée et implémenté en MPI. OP2 est une version plus moderne et plus récente de
OPlus qui est implémentée pour les architectures à mémoire partagée CPU et GPU.
Le framework OP2 charge l’utilisateur de quatre parties : (1) définir des ensembles
d’éléments qui vont définir les éléments du maillage, (2) définir des liens entre ces
ensembles pour former le maillage, (3) définir des données sur les ensembles, et enfin (4)
implémenter des opérations sur les ensembles d’éléments. Il est ainsi possible de définir
un maillage non-structuré de toute forme, ainsi que sa topologie. Le framework dispose
d’un compilateur permettant de transformer le code OP2 en un code C++, qui pourra
à son tour être compilé. Le DSL Liszt [50] permet également de coder des simulations
sur maillages non-structurés en parallèle. Toutefois, le niveau d’abstraction proposé à
l’utilisateur est légèrement différent. En effet, le niveau d’abstraction de OP2 est plus
proche du niveau d’abstraction des squelettes algorithmiques puisque l’utilisateur n’a pas
à définir ses boucles, alors que Liszt permet de rester plus proche d’un code séquentiel
avec la gestion des boucles par l’utilisateur. Le langage Liszt est une sous-partie du
langage de programmation Scala [99], et utilise une version modifiée de son compilateur.
Liszt supporte une implémentation MPI, OpenMP et CUDA/OpenCL.
Dans une solution de parallélisme implicite spécifique, comme celles qui ont été évoquées
ici, il est nécessaire de doser convenablement le niveau de spécificité de la solution.
D’une part, si le niveau de spécificité est trop important, cela peut nuire au nombre
d’utilisateurs. D’autre part, et à l’inverse, si le niveau de spécificité est trop faible, la
solution peut s’avérer trop généraliste et risque de ne pas répondre aux attentes des utilisateurs.
Toutefois, en trouvant un niveau de spécificité adéquat, ces solutions sont souvent
celles qui procurent les meilleures performances, et la plus grande notoriété auprès des
utilisateurs non-informaticiens.56 Chapitre 2. Etat de l’art
2.5 Calculs de performances et difficulté de programmation
Dans cette thèse est proposée l’implémentation d’une solution de parallélisme implicite
pour les simulations basées sur des maillages. L’évaluation de cette implémentation
passe donc par deux volets, tout d’abord l’évaluation des performances produites par la
solution de parallélisme implicite, mais également l’évaluation de la difficulté de codage
liée à l’utilisation cette solution. Il existe un lien fort entre les performances d’une solution
de parallélisme implicite et sa simplicité de codage, puisque le fait de cacher des
opérations parallèles peut engendrer un certain sur-coût. Une solution de parallélisme
implicite idéale sera à la fois performante et très simple d’utilisation. Cette dernière section
de notre état de l’art va donc tout d’abord introduire les mesures de performances,
puis les métriques d’effort utilisées dans cette thèse.
2.5.1 Mesures de performances
En science informatique, on appelle benchmarking une méthode permettant de quantifier
et d’évaluer les résultats expérimentaux d’un code ou d’un programme. Plus particulièrement,
en calcul parallèle, le benchmarking est souvent associé aux méthodes
permettant d’évaluer les performances d’un programme, de façon absolue, ou de façon
relative à un autre programme. L’idée de base est de calculer le temps d’exécution d’un
programme, ou d’une sous-partie du programme, représentative du problème à évaluer.
Ce temps d’exécution peut ensuite être utilisé pour évaluer plusieurs types de métriques
comme le temps d’exécution total et moyen du programme, l’accélération du programme,
le débit des échanges de données par le programme (exprimés en bits par seconde), ou
encore la puissance du programme (exprimé en nombre d’opérations flottantes, par seconde).
Certaines de ces métriques peuvent être comparées de façon absolue avec un idéal
de référence. C’est le cas, par exemple, de la puissance du programme qui ne peut en
théorie pas dépasser les capacités matérielle des machines utilisées. D’autres métriques,
en revanche, ne sont utiles que dans le cas d’une comparaison avec d’autres programmes,
comme par exemple le temps d’exécution.
Dans cette thèse deux métriques en particulier seront utilisées pour évaluer les performances
des solutions proposées. La première est la représentation de l’accélération d’un
programme, qui nous permettra d’évaluer la montée en charge des programmes parallèles
implémentés. Nous appellerons cette métrique la scalabilité. Il existe deux méthodes
pour évaluer la scalabilité d’un programme parallèle. La première est appelée la scalabilité
faible, la seconde la scalabilité forte. Notons T(p, n) le temps nécessaire pour exécuter
un programme en utilisant p processeurs et pour une taille de problème n. On définit
alors l’accélération du programme, pour un problème de taille n et pour p processeurs,
par
speedup(p, n) = Tseq(1, n)
T(p, n)
, (2.19)2.5. Calculs de performances et difficulté de programmation 57
où Tseq(1, n) représente le meilleur temps séquentiel connu pour la résolution du problème
courrant, alors que T(p, n) représente le temps du programme parallèle à évaluer. Il
ne s’agit donc pas du même programme et le temps Tseq(1, n) est considéré comme la
référence du problème. Cette définition de l’accélération permet d’évaluer une scalabilité
dite forte et comparable entre différentes verions parallèles. La taille du problème n’est pas
modifiée entre l’exécution séquentielle (sur un unique processeur) et l’exécution parallèle
(sur p processeurs). Toutefois, étant donné qu’il est nécessaire de disposer de Tseq(1, n)
pour évaluer cette l’accélération, une version modifiée de cette définition est généralement
utilisée, et sera utilisée dans cette thèse. L’accélération est alors définie par
speedup(p, n) = T(1, n)
T(p, n)
. (2.20)
Dans ce cas c’est le temps séquentiel du programme à évaluer qui est utilisé comme temps
de référence. Cette accélération est moins intéressante et ne permet pas une comparaison
intéressante de deux versions parallèles différentes. Toutefois, elle permet d’observer la
scalabilité du programme à évaluer.
Une deuxième définition de l’accélération du programme, pour un problème de taille
n et pour p processeurs, est
speedup(p, n) = T(1, n)
T(p, p × n)
.
Dans ce cas, la scalabilité évaluée est dite faible car la taille du problème est multipliée
par le nombre de processeurs utilisés. Ainsi, la quantité de travail assignée à chaque processeur
reste constante et le nombre de processeurs utilisés (et donc la taille du problème
général) augmente. Cette accélération est idéale si le temps de calcul reste constant avec
l’augmentation du nombre de processeurs et de la taille du problème. Cette accélération
peut être utile dans plusieurs cas. Tout d’abord si le programme parallèle contient
une fraction de code séquentielle et une fraction de code parallèle, cette accélération
peut donner des indications sur le temps représenté par la fraction séquentielle et sur
la nécessité de réduire cette fraction. Une fraction de code parallèle que nous appelons
classique, c’est à dire utilisant relativement peu de communications (les plus proches
voisins par exemple), ne rencontre pas de problème de scalabilité dans le cas d’une accélération
faible. Si, en revanche, le programme parallèle utilise des communications très
lourdes, telles que des communications collectives dont le coût augmente avec le nombre
de processeurs, certains problèmes peuvent être détectés en représentant cette accélération.
Enfin, si le programme consomme beaucoup de mémoire et ne peut, par exemple,
pas être exécuté en séquentiel, cette accélération peut permettre d’évaluer tout de même
son accélération. Dans cette thèse, nous utiliserons la définition de l’accélération forte
modifiée (2.20) pour évaluer la scalabilité des programmes.
Si l’on évalue l’accélération (2.20) d’un programme parallèle en utilisant P processeurs,
on représente généralement une courbe de l’ensemble des valeurs de l’accélération
entre 1 et P processeurs. Cette courbe représente donc l’accélération du programme
parallèle en fonction du nombre de processeurs utilisés. Étant donné la définition de l’accélération
(2.20), il peut être déduit que l’accélération idéale d’un programme est égale58 Chapitre 2. Etat de l’art
à p pour tout p dans [1, P]. L’accélération idéale est alors représentée par la fonction
f(p) = p. Il en résulte qu’un bon speedup sera idéalement le plus proche possible de la
droite f(p) = p, et idéalement linéaire quelque soit le nombre de processeurs utilisés.
Toutefois la réalité sur l’accélération d’un programme peut être différente de ces déductions
théoriques. Il est en effet possible de dépasser l’accélération idéale théorique. C’est
ce qu’on appelle une accélération super-linéaire. Ce phénomène peut être dû à plusieurs
facteurs. La première raison concerne les architectures à mémoire distribuées. Imaginons
alors que le programme séquentiel utilise trop de mémoire, il est alors possible que les
données du programme ne tiennent plus en mémoire vive et soient stockées sur la mémoire
disque de la machine (ce qu’on appelle du swapping). Dans ce cas, le temps d’exécution
séquentiel peut être anormalement long. Ainsi, en augmentant le nombre de processeurs,
et donc en réduisant l’emprunte mémoire du programme pour chaque processeur, l’accé-
lération peut être facilement supérieure à p. La deuxième raison qui peut être à l’origine
d’une super-linéarité est proche de la première mais non restreinte aux architectures à
mémoire distribuée. Elle ne concerne plus le passage de la mémoire vive à l’espace disque,
mais le passage de la mémoire cache à la mémoire vive. En réduisant la taille du problème
(du fait du nombre de processeurs utilisés), il peut arriver que la taille des structures de
données sur lesquelles sont effectués les calculs soit inférieure ou égale à la taille des
lignes de cache, ce qui réduit le nombre d’accès à la mémoire vive depuis le cache, et
ce qui augmente l’efficacité du programme. Cependant, lorsqu’un phénomène de cache
se produit et qu’il conduit à une accélération super-linéaire, il peut être intéressant de
modifier l’algorithme séquentiel afin que celui-ci traite des blocs de données plus petits,
limitant ainsi les chargements de données en cache pendant les calculs. De cette façon,
la super-linéarité est souvent réduite et l’accélération observée sera probablement plus
réaliste.
L’évaluation de la scalabilité forte d’un programme est un bon indicateur des performances
du programme parallèle. Toutefois, l’accélération définie par (2.20), et utilisée
dans cette thèse, souffre de certaines faiblesses lorsque l’on cherche à comparer les performances
de deux implémentations différentes. Tout d’abord l’accélération d’un programme
est liée au temps d’exécution séquentiel du programme. On ne peut donc pas comparer
l’accélération de deux implémentations n’ayant pas le même temps d’exécution séquentiel.
De même, meilleure est l’implémentation, plus difficile est l’obtention d’une bonne
accélération, car la version moins optimisée passera plus de temps dans les calculs et
aura probablement une accélération linéaire sur un plus grand nombre de processeurs.
Une accélération est donc un bon indicateur de scalabilité pour un programme, mais
la définition (2.20) n’est pas une bonne métrique pour comparer deux implémentations
différentes. Pour cette raison nous utilisons une deuxième métrique dans cette thèse. Elle
représente simplement le temps d’exécution des programmes et nous permettra de comparer
de façon objective les temps d’exécution de plusieurs implémentations parallèles
d’une même simulation scientifique. Notons que nous avons fait le choix, dans la plupart
de nos résultats, de représenter les temps d’exécution avec une échelle logarithmique.
Cette échelle nous permet, tout d’abord, de faciliter la lisibilité des temps d’exécution,2.5. Calculs de performances et difficulté de programmation 59
mais permet également de comparer à la fois les temps d’exécution et la linéarité des
accélérations des implémentations.
2.5.2 Effort de programmation
Il existe diverses métriques permettant d’évaluer l’effort à fournir pour écrire un
code. Certaines métriques permettent d’évaluer la difficulté d’un programme à partir du
nombre de fonctionnalités à implémenter [4,80]. Ce type de métriques est utilisé dans la
conception et le génie logiciel, mais ne nous sera pas utile pour comparer deux versions
parallèles possédant les mêmes fonctionnalités. La complexité cyclomatique [91] est une
métrique qui base la difficulté de programmation sur le comportement d’un programme.
Elle est basée sur un graphe qui représente les différentes exécutions possibles d’un même
programme, pour en estimer sa complexité. En d’autres termes, cette métrique s’intéresse
aux branches conditionnelles du programme. De nouveau, nous ne pourrons utiliser cette
métrique pour comparer deux implémentations d’une même simulation. Nous pouvons
noter deux métriques qui pourraient être utilisées dans notre cas. Tout d’abord, la mé-
trique SLOC (Source Lines of Code) se base uniquement sur le nombre de lignes dans un
code pour évaluer la difficulté d’un programme. Il s’agit d’une première indication sur
l’effort à fournir pour écrire un programme. Mais nous allons plus particulièrement nous
concentrer sur les métriques de Halstead [65], qui offrent des indicateurs plus révélateurs
sur l’effort de programmation à fournir pour écrire un code.
Les métriques de Halstead sont basées sur le dénombrement des opérateurs et des
opérandes d’un code source. De ce dénombrement, directement appliqué dans le code,
sont obtenues quatre mesures représentées dans la table 2.2. Afin d’évaluer correctement
ces mesures il est important de définir ce que l’on considère comme un opérateur et un
opérande. Peu d’informations sur ce sujet sont données dans la littérature. Dans nos
codes C++, nous avons considéré que les opérandes étaient l’ensemble des variables et
constantes définies par l’utilisateur. Les opérateurs sont l’ensemble des opérations numériques,
affectations et opérateurs de comparaison (+,∗,−,/,=,==,&&,<= etc.), l’ensemble
des mots clés du C++ (static, class, template etc.), l’ensemble des types (int,
const, float, ∗ etc.), l’ensemble des instructions du C++ (for, while, do, if /elseif /else
etc.), les symboles délimiteur ;, les parenthèses et les appels de fonctions.
Symbole Mesure
N1 Nombre total d’opérateurs
N2 Nombre total d’opérandes
η1 Nombre d’opérateurs distincts
η2 Nombre d’opérandes distincts
Table 2.2 – Mesures directes dans le code
Grâce à ces quatre mesures, les métriques de Halstead peuvent être calculées et sont
représentées dans la table 2.3. La première représente le vocabulaire du programme, la
deuxième la longueur du programme, qui n’est pas directement liée aux nombre de lignes60 Chapitre 2. Etat de l’art
de code mais au nombre total d’opérandes et d’opérateurs. La troisième métrique repré-
sente le volume du programme. Ce volume est le produit de la longueur du programme
et du logarithme en base deux du vocabulaire. Comme ce volume est basé sur le nombre
d’opérations effectuées et d’opérandes gérées dans le programme, il est moins sensible à
la disposition du code que les mesures SLOC. La métrique suivante représente la diffi-
culté, et la propension à l’erreur, d’un programme. Cette métrique est calculée comme un
produit entre le vocabulaire des opérateurs et la fréquence d’apparition des opérandes.
Elle part donc du principe que plus le nombre d’opérateurs distincts est grand, plus il
est difficile d’implémenter le programme, et que plus les même opérandes sont utilisées
dans le programme, plus la propension à l’erreur est grande. Enfin la dernière métrique
représente l’effort nécessaire à l’écriture du programme et est égale au produit du volume
par la difficulté. Ainsi, plus un programme est volumineux et difficile, plus l’effort de
programmation à fournir sera important.
Symbole Valeur Métrique
η η1 + η2 Vocabulaire
N N1 + N2 Longueur
V N × log2 η Volume
D
η1
2 ×
N2
η2
Difficulté
E D × V Effort
Table 2.3 – Métriques de Halstead
Les métriques de Halstead proposent donc des concepts intéressants et permettent de
pouvoir comparer deux implémentations différentes, en terme d’effort de programmation,
ce qui n’est pas le cas des autres métriques. Même si ces métriques ont été proposées
pour des programmes séquentiels, elles s’appliquent, de notre point de vue, à des programmes
parallèles. Toutefois, notons que ces métriques comportent des faiblesses pour
exprimer l’effort de programmation d’un programme parallèle. Tout d’abord, dans un
programme parallèle, un effort plus important est demandé aux utilisateurs pour utiliser
des opérateurs parallèles et des opérandes distribuées, que pour utiliser des opérateurs
et opérandes classiques de la programmation séquentielle. De plus, dans la conception
d’un programme parallèle, les appels à des opérateurs parallèles (comme par exemple
les routine de MPI), et l’utilisation d’opérandes distribuées ne sont pas les seuls diffi-
cultés. En effet, l’un des points les plus difficiles dans la programmation parallèle est la
conception du programme. C’est, en effet, le développeur qui doit réfléchir à la façon
dont le programme va pouvoir fonctionner en parallèle et ce n’est pas une difficulté qui
peut transparaître dans le dénombrement des opérandes et des opérateurs. Il paraît très
difficile de pouvoir évaluer ce type de difficulté, aussi les métriques de Halstead restent,
de notre point de vue, les métriques les plus adaptées à l’utilisation que nous souhaitons
en faire dans cette thèse.2.6. Conclusion et positionnement du travail 61
2.6 Conclusion et positionnement du travail
Cette thèse vise à proposer des solutions de parallélisme implicite pour le cas spé-
cifique des simulations numériques scientifiques. Pour cette raison, cet état de l’art a,
tout d’abord, évoqué les architectures parallèles, les paradigmes et les modèles de programmation
et leur évolution au fil du temps, ainsi que la discrétisation et les méthodes
numériques de résolution des EDP. Dans les concepts introduits dans la section 2.2 de cet
état de l’art, nous nous intéressons plus particulièrement aux résolutions d’EDP basées
sur des maillages quelconques, en utilisant les méthodes numériques introduites dans la
partie 2.2.4. Le travail présenté dans cette thèse se limite, dans les chapitre 3 et 4, aux
calculs de schémas numériques explicites (2.3), toutefois le chapitre 5 évoquera et traitera
le cas de schémas numériques implicites (2.4) également. Cette thèse propose des modèles
et des implémentations basés sur le paradigme de parallélisme de données et sur le paradigme
SPMD. Pour cette raison, le problème de partitionnement des données est un point
à aborder et que nous traitons plus particulièrement dans le chapitre 5. Nous présentons
dans cette thèse un modèle de programmation implicite nommé SIPSim (pour Structured
Implicit Parallelism for Scientific SIMulations ), puis par la suite son implémentation
pour des architectures à mémoire distribuée, nommée SkelGIS, qui permet de valider le
modèle. L’approche SIPSim s’applique, a priori, à tout type de maillage, toutefois cette
thèse s’intéresse à l’implémentation de deux cas particuliers : les maillages cartésiens à
deux dimensions et la composition de maillages sous forme de réseaux. Le modèle SIPSim
permet de générer des programmes parallèles SPMD du type de l’algorithme 2, en
conservant une programmation séquentielle comme introduite dans l’algorithme 1. En-
fin, l’implémentation actuelle de l’approche SIPSim (SkelGIS) est effectuée en utilisant
le modèle MPI, introduit dans cet état de l’art.
C’est dans la partie 2.4 de cet état de l’art qu’a été introduit le cœur de cette thèse
en décrivant les modèles et les solutions de parallélisme implicite. Les avantages et les
inconvénients de chaque vision du parallélisme implicite ont été donnés et analysés, ce
qui nous permet de positionner notre travail dans ce contexte. La figure 2.12 résume ce
positionnement, que l’on peut aussi résumer ainsi :
— Des bibliothèques générales de parallélisme implicite, notre travail tente de conserver
une certaine flexibilité, ou souplesse, qui permet de répondre notamment à des
cas particuliers de simulations. Nous héritons par exemple du concept très important
d’itérateur de STAPL ou de PSTL, sous une forme différente.
— Des solutions à patrons, et des bibliothèques de squelettes algorithmiques, notre
travail hérite d’un haut niveau d’abstraction. L’utilisateur définit, en effet, des
fonctions séquentielles qui sont appliquées au travers de patrons où les communications
entre les processeurs lui sont cachées. Nos travaux sont notamment proches
des concepts introduits par le modèle Pregel, proche de BSP.
— Enfin, des langages et bibliothèques spécifiques, notre travail cherche à retrouver
une efficacité propre aux problèmes spécifiquement traités, par le biais d’optimisations.
Le framework OP2 et le DSL Liszt sont les solutions spécifiques les plus
proches de nos travaux.62 Chapitre 2. Etat de l’art
Solutions spécifiques Patrons et squelettes
SIPSim
Solutions générales
Optimisation Abstraction
Flexibilité
Figure 2.12 – Placement de notre travail par rapport à l’existant.
Enfin, afin de comprendre les résultats présentés dans cette thèse, nous avons terminé
cet état de l’art par une présentation des mesures de performance et des mesures de
difficulté de programmation.3
SIPSim : Structured Implicit
Parallelism for scientific
Simulations
Sommaire
4.1 SIPSim pour les maillages réguliers à deux dimensions . . . . . 74
4.1.1 Structure de données distribuée . . . . . . . . . . . . . . . . . . . . . . . 74
4.1.2 Applicateurs et opérations . . . . . . . . . . . . . . . . . . . . . . . . . . 77
4.1.3 Interfaces de programmation . . . . . . . . . . . . . . . . . . . . . . . . . 78
4.1.4 Spécialisation partielle de template . . . . . . . . . . . . . . . . . . . . . 80
4.2 Résolution numérique de l’équation de la chaleur . . . . . . . . 82
4.2.1 Équation et résolution numérique . . . . . . . . . . . . . . . . . . . . . . 82
4.2.2 Parallélisation avec SkelGIS . . . . . . . . . . . . . . . . . . . . . . . . . 83
4.2.3 Résultats . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85
4.3 Résolution numérique des équations de Saint Venant . . . . . . 89
4.3.1 Équations de Saint Venant . . . . . . . . . . . . . . . . . . . . . . . . . . 89
4.3.2 Résolution numérique et programmation . . . . . . . . . . . . . . . . . . 90
4.3.3 Parallélisation avec SkelGIS . . . . . . . . . . . . . . . . . . . . . . . . . 92
4.3.4 Résultats . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 94
4.4 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99
6364 Chapitre 3. SIPSim : Structured Implicit Parallelism for scientific Simulations
Dans cette thèse nous nous intéressons aux simulations scientifiques dont les équations
aux dérivées partielles sont résolues par des méthodes numériques qui discrétisent l’espace
et le temps. On appelle ces simulations des simulations basées sur des maillages. Nous
nous intéressons plus précisément, et dans un premier temps, aux simulations dont les
schémas numériques sont explicites et donc de la forme de l’équation (2.3) présentée
dans la section 2.2.3.2. En informatique, ce type de calcul est appelé un calcul stencil.
Le parallélisme implicite pour des calculs de type stencil est un domaine très actif de la
recherche en informatique. Dans ce chapitre est présentée la méthode SIPSim, qui signifie
Structured Implicit Parallelism for scientific Simulations. SIPSim permet d’obtenir une
vision systématique des besoins pour proposer une solution de parallélisme implicite
pour ce type de simulations. SIPSim peut donc être considéré comme un modèle de
programmation parallèle implicite pour les simulations scientifiques.
Afin de définir une approche pertinente pour élaborer des solutions de parallélisme
implicite pour les simulations scientifiques, il faut tout d’abord étudier la parallélisation
de ces simulations scientifiques. Comme nous l’avons déjà décrit dans l’état de l’art 2.4,
Pingali et Al [103] ont défini “The TAO of Parallelism in Algorithms”, qui propose une
classification intéressante des différents types de problèmes à paralléliser. Une fois un
problème classifié dans le “TAO”, des solutions connues de parallélisation peuvent être
appliquées. La parallélisation est donc facilitée grâce à cette classification, mais en aucun
cas cachée, comme nous cherchons à le faire. Comme nous l’avons déjà détaillé dans la
section 2.4.1 de l’état de l’art, le type de simulations auxquelles nous nous intéressons
dans ce travail (sur maillages fixes et schémas numériques explicites) sont classifiées
dans le “TAO” comme des algorithmes topology-driven, dont l’ensemble des éléments du
maillage sont identifiés comme les nœuds actifs (active nodes) de l’algorithme, et peuvent
être traités de façon non-ordonnée dans l’algorithme. Enfin, les calculs sont considérés
comme locaux car ne modifiant pas le maillage d’entrée.
Comme il l’a déjà été évoqué précédemment, pour des architectures parallèles à mé-
moire distribuée, ce genre de simulations est généralement parallélisé en utilisant l’approche
SPMD (Simple Program Multiple Data) décrite dans l’état de l’art. Cette approche
se prête bien aux simulations basées sur les maillages puisqu’elle consiste alors
à partitionner le maillage en plusieurs parties, chacune confiées à des processeurs diffé-
rents qui exécuteront le même code sur leur sous-partie du maillage. L’algorithme 2 de
la partie 2.2.5 illustre ce type de parallélisation, et représente la base de l’analyse de
l’approche SIPSim. Nous rappelons ici cet algorithme avec plus de détails. Nous notons
Sb le schéma numérique à appliquer aux éléments de la bordure physique du maillage,
qui correspond donc à calculer les conditions limites. Nous notons, de plus, S le schéma
numérique permettant le calcul des quantités pour les autres éléments du maillage.
Cet algorithme parallèle peut très clairement être apparenté au modèle BSP, introduit
lui aussi dans l’état de l’art. En effet, dans cet algorithme peuvent être identifiées trois
super-étapes. Tout d’abord une super-étape de communication est effectuée au début de
chaque itération de temps. Dans cette étape, chaque processeur reçoit les valeurs sur le
voisinage N(x), qu’il ne possède pas dans son sous-maillage, afin de pouvoir calculer,
de façon correcte, l’ensemble des nouvelles valeurs pour la nouvelle itération de temps.65
Algorithme 3 : Algorithme parallèle SPMD d’une simulation basée sur un
maillage.
Création du maillage µ
Partitionnement du maillage µ = {µ0, µ1, . . . µp−1}
Création des quantités à simuler appliquées à µ
Initialisation des quantités et des paramètres
Définition du pas de temps, commun à tous les processeurs : t
Définition du temps maximal, commun à tous les processeurs : tmax
tant que t struct DMatrix
template struct DMatrix
template struct DMatrix
template struct DMatrix
Figure 4.4 – Spécialisation partielle de template pour l’objet DMatrix : T est le type de donnée
à stocker dans l’instance, Or est l’ordre de la simulation, et box est le type de connectivité
souhaitée. Ce paramètre a une valeur par défaut à false (star est le choix par défaut).
Trois paramètres de template sont définis pour l’objet DMatrix. Le premier paramètre,
T, indique le type de données qui va être stocké dans l’instance de l’objet. Le82 Chapitre 4. SkelGIS pour des maillages réguliers à deux dimensions
paramètre Or indique ensuite l’ordre nécessaire pour l’instance de l’objet DMatrix. Ce
paramètre peut sembler inutile pour l’objet DMatrix en lui-même, puisque l’ordre est
une indication qui concerne la simulation et non chaque quantité simulée. Toutefois, il
est important de demander cette information au niveau de l’objet DMatrix pour rendre
la solution plus efficace. En effet, dans une simulation, toutes les quantités à simuler
sont nécessaires au calcul du ou des schémas numériques de type (2.3), toutefois, toutes
les quantités à simuler ne participent pas aux calculs faisant intervenir N(x). Demander
l’ordre pour chaque instance de l’objet DMatrix permet donc d’éviter des communications
inutiles lorsque l’instance n’a pas besoin de voisinages. Enfin, le troisième paramètre
de la classe template, box, est un booléen avec une valeur par défaut à false. Ce paramètre
indique la connectivité du maillage qui va être définie en instanciant l’objet DMatrix. En
effet, rappelons ici que l’objet DMatrix a la particularité de regrouper deux composants
de la méthode SIPSim, la structure de données distribuée (DDS), et l’application de
données sur cette structure de données (DPMap). L’instanciation d’un objet DMatrix
correspond donc bien à la définition d’un maillage et il n’est pas impossible qu’une simulation
complexe instancie différents types de connectivités pour ses différentes quantités
à simuler.
Trois spécialisations partielles des paramètres de cette classe sont proposées et données
dans la figure 4.4. Tout d’abord, en conservant la valeur par défaut du paramètre
box, le paramètre Or est spécialisé avec la valeur 0. Cette spécialisation permet d’indiquer
qu’une quantité utilisée dans la simulation ne participe pas aux calculs faisant intervenir
N(x). Autrement dit, ce type de quantité est utilisé localement, sans notion de voisinage.
Cette spécialisation est très importante pour les performances de la solution puisqu’alors
aucune communication MPI ne sera nécessaire. La deuxième spécialisation fixe le paramètre
box avec une valeur à true. Cette spécialisation active donc la connectivité box. Dans
cette spécialisation, les interfaces de voisinage proposées à l’utilisateur seront différentes.
Enfin la troisième spécialisation fixe le paramètre Or à 0 et le paramètre box à true, ce qui
combine les deux spécialisations précédemment décrites. Chaque spécialisation de l’objet
DMatrix propose une implémentation de la classe qui lui est propre, ce qui alourdit considérablement
le code de la bibliothèque (mais pas le code utilisateur). Cependant, cette
solution est très intéressante puisque l’utilisateur ne manipule qu’une unique classe. De
plus, les performances obtenues sont également très intéressantes puisque, dans le code,
les conditions concernant l’ensemble de ces paramètres disparaissent. Enfin, le choix de
la bonne implémentation de classe est effectué à la compilation et non à l’exécution ce
qui rend cette solution efficace.
4.2 Résolution numérique de l’équation de la chaleur
4.2.1 Équation et résolution numérique
L’équation de la chaleur à deux dimensions est définie par
∂u
∂t =
∂
2u
∂x2
+
∂
2u
∂y2
,4.2. Résolution numérique de l’équation de la chaleur 83
où u(x, y, t) représente la température au point (x, y) et à l’itération de temps t.
Le schéma explicite des différences finies est le schéma le plus simple pour résoudre
l’équation de la chaleur. Il consiste en une discrétisation du domaine en espace avec le
maillage {xi
, yj}i,j , avec xi = i∆x et yj = j∆y. ∆x = xi+1 − xi et ∆y = yi+1 − yi sont
les intervalles en espace suivant les deux dimensions. Soit ∆t l’intervalle de temps entre
chaque itération, supposons qu’à un temps tn = n∆t, la valeur u
n
i,j = u(xi
, yj , tn) est
connue pour chaque élément du maillage. Alors, en utilisant le développement polynomial
de Taylor, la solution à l’instant tn+1 est donnée par le schéma suivant :
u
n+1
i,j − u
n
i,j
∆t
=
u
n
i+1,j − 2u
n
i,j + u
n
i−1,j
∆x
2
+
u
n
i,j+1 − 2u
n
i,j + u
n
i,j−1
∆y
2
.
En supposant que ∆x = ∆y, alors le schéma devient :
u
n+1
i,j = (1 − 4λ)u
n
i,j + λ(u
n
i+1,j + u
n
i−1,j + u
n
i,j+1 + u
n
i,j−1
), (4.9)
où λ := ∆t
∆x2 ≤
1
2
garantit la stabilité du schéma numérique.
4.2.2 Parallélisation avec SkelGIS
Le schéma numérique de l’équation (2.3) offre toutes les informations nécessaires
pour coder la simulation en utilisant SkelGIS. Tout d’abord, seule la quantité u nécessite
d’être manipulée dans le schéma. Ensuite, comme le calcul pour l’itération de temps n+1
dépend des résultats de l’itération de temps n, deux instanciations de l’objet DMatrix
sont nécessaires pour stocker successivement les données d’entrée et de sortie. De plus,
le schéma nous donne l’indication qu’une connectivité de type star est nécessaire pour
chaque calcul. En effet, les éléments (i + 1, j) (droit), (i − 1, j) (gauche), (i, j + 1) (bas)
et (i, j − 1) (haut) sont utilisés par le schéma. L’ordre de la simulation est égal à 1 car
aucun élément aux positions i ± 2 ou j ± 2 ne sont nécessaires. Enfin, il y a un unique
schéma à appliquer à chaque itération de temps, ce qui nous indique qu’un unique appel
à un applicateur sera nécessaire pour cette résolution.
La figure 4.5 donne le code de la fonction main du programme de résolution de
l’équation de la chaleur en utilisant SkelGIS. Deux instanciations de l’objet DMatrix sont
tout d’abord effectuées (lignes 12 et 14). Par la suite, la boucle en temps est initialisée
(ligne 15). A chaque itération de temps, l’applicateur est appelé avec l’opération laplacien
(ligne 17) pour résoudre le schéma. Enfin, les DMatrix d’entrée et de sortie sont échangées
pour l’itération en temps suivante (lignes 18 à 20). Notons ici que la création de m3 à la
ligne 18 ne fait que copier l’adresse du pointeur qui est caché derrière l’objet m. Cette
étape n’est donc pas coûteuse en temps d’exécution. Il reste toutefois quelques détails
non précisés dans la figure 4.5. Les instructions INIT SKELGIS et ENDSKELGIS,
tout d’abord, permettent d’initialiser de façon implicite la bibliothèque MPI et certaines
variables utiles à SkelGIS. La classe HEADER permet simplement de définir l’en-tête
d’un maillage cartésien à deux dimensions suivant une largeur et une hauteur (width et
height) et suivant une coordonnée en haut à gauche du maillage (x et y). Il est également84 Chapitre 4. SkelGIS pour des maillages réguliers à deux dimensions
1 #include " s k e l g i s / s k e l g i s . hpp"
2 using namespace s k e l g i s ;
3
4 int main ( int argc , char∗∗ argv )
5 {
6 INITSKELGIS ;
7 HEADER head ;
8 head . x=0; head . y=0;
9 head . width =100; head . h ei g h t =100;
10 head . s p a ci n g =1; head . nodata=−9999;
11
12 DMatrix m( head , 0 ) ;
13 m. se tGl o b alMid dleV alue ( 1 ) ;
14 DMatrix m2( head , 0 ) ;
15 for ( int i =0; i <100; i++)
16 {
17 ApplyUnary:: apply ( l a p l a c i e n ,m,m2 ) ;
18 DMatrix m3(m) ;
19 m = m2;
20 m2 = m3;
21 }
22 ENDSKELGIS;
23 }
Figure 4.5 – Fonction main du programme de résolution de l’équation de la chaleur avec
SkelGIS.
possible de préciser un nombre flottant représentant la hauteur et la largeur d’une maille,
et de pouvoir identifier une valeur qui représente une maille sans donnée. Son nom est
historiquement conservé de l’en-tête des fichiers représentant le terrain dans les SIG
(Système d’Information Géographiques). Enfin, la méthode setGlobalM iddleV alue(1)
permet d’initialiser une valeur à 1 au centre du maillage. Notons qu’il aurait également
été possible d’initialiser ce maillage par un fichier.
La figure 4.6 donne ensuite le code de l’opération laplacien qui est appliquée dans
la fonction main. Cette opération calcule le schéma numérique (4.9) et est appliquée à
chaque itération de temps. Tout d’abord un itérateur est initialisé au début de la DMatrix
d’entrée. Un autre itérateur est initialisé à la fin de cette même DMatrix (lignes 4 et 5).
Pour chaque élément du maillage (ligne 6), le schéma est calculé (ligne 8 à 10) et le résultat
est écrit dans la DMatrix de sortie (ligne 11). Les macros C++ “BEGINApplyUnary”
et “END” aux lignes 1 et 13, servent à identifier le début et la fin de la définition de
l’opération laplacien.
Cet exemple illustre que le code SkelGIS reste très proche d’un code séquentiel. Aucune
difficulté n’est introduite dans ce code puisque les interfaces et les paramètres demandés
sont connus de l’utilisateur. Il peut aussi être noté qu’aucune utilisation de
pointeurs n’est nécessaire dans le code SkelGIS, ce qui simplifie son utilisation. La bibliothèque
gère en effet elle-même la création et la destruction des pointeurs dont elle4.2. Résolution numérique de l’équation de la chaleur 85
1 BEGINApplyUnary ( l a p l a c i e n ,m, double , 1 ,m2, double , 1 )
2 {
3 double a = 0 . 0 5 ;
4 i t e r a t o r i t = m. be gi n ( ) ;
5 i t e r a t o r itEnd = m. end ( ) ;
6 for ( i t ; i t h
DMatrix u
DMatrix v
Figure 4.11 – Déclaration des variables h, u et v
type double, le paramètre Or est égal à 2 et la valeur par défaut du paramètre box est
utilisée. Notons que les autres variables de la simulation sont, pour la plupart, soit du
type DM atrix < double, 2 > soit du type local DM atrix < double, 0 >.
L’algorithme 7 illustre la fonction principale de la simulation. Bien entendu, FullSWOF2D
est un logiciel complexe écrit en langage objet C++, la fonction main de cette
application ne ressemble donc pas réellement à celle présentée ici. Toutefois, nous essayons
ici de mettre en avant les concepts de l’utilisation de SkelGIS. La fonction principale
d’une telle simulation consisterait donc, tout d’abord, en l’instanciation de l’objet DMatrix
pour les trois quantités simulées ainsi que pour l’ensemble des variables nécessaires
aux calculs de la simulation. Tout comme dans le programme séquentiel, ces variables et
quantités seraient ensuite initialisées. Pour cela des opérations peuvent être définies et
appelées par l’intermédiaire d’applicateurs. Il est aussi possible de mettre une valeur par
défaut dans ces variables, ou encore de les initialiser à l’aide d’un fichier de données. Une
fois ces initialisations effectuées, la boucle principale de la simulation en temps est démarrée.
Elle est suivie, tout comme dans l’algorithme séquentiel, d’une boucle for de deux
itérations permettant d’appliquer les schémas à l’ordre 2 pour plus de précision. Dans
cette boucle est ensuite appelé un applicateur. Notons ici qu’un ensemble d’applicateurs
aurait aussi pu être appelé pour partitionner la simulation et l’organiser de façon plus4.3. Résolution numérique des équations de Saint Venant 93
claire. Tout dépend du code séquentiel initial et des dépendances entre les données, tout
comme dans l’implémentation séquentielle. Nous présentons ici une solution ne faisant
appel qu’à un applicateur, pour simplifier l’explication. La véritable implémentation de
FullSWOF2D se décompose en plusieurs appels à des applicateurs répartis dans différents
objets du code. Pour terminer, un deuxième applicateur est appelé afin d’effectuer les
calculs nécessaires pour appliquer le schéma à l’ordre 2.
Algorithme 7 : Fonction main codée par l’utilisateur
DM atrix < double, 2 > h
DM atrix < double, 2 > u
DM atrix < double, 2 > v
...
Initialisation des quantités et des paramètres
Définition du pas de temps : t
Définition du temps maximal : tmax
while t < tmax do
for i dans J0, 2J do
apply_list({h,u,v,etc.},fullswof)
end
apply_list({h,u,v,etc.},heun)
end
Le premier applicateur appelé dans l’algorithme 7 va donc être en charge d’appliquer
une opération contenant le calcul des conditions limites, de la reconstruction, des flux,
du schéma numérique et des frottements. Cette opération est décrite dans l’algorithme 8.
Pour rappel, la méthode SIPSim recommande de proposer au moins deux itérateurs
différents dans l’interface de programmation, le premier pour parcourir les éléments de
la bordure physique sans surcoût de conditions, et le deuxième pour les autres éléments
du maillage. Cette recommandation a été suivie dans l’implémentation de SkelGIS pour
les maillages cartésiens, comme cela a été décrit dans la partie 4.1.3. Pour cette raison,
l’opération fullswof va tout d’abord parcourir les éléments de la bordure physique du
maillage grâce aux itérateurs adaptés (qui sont en fait au nombre de quatre). L’opérateur
[] et les fonctions de voisinage sont ensuite utilisés afin de calculer les conditions limites
de la simulation. Par la suite, l’itérateur contigu mis en place dans SkelGIS est utilisé
afin de naviguer dans l’ensemble des éléments du maillage qui ne font pas partie de la
bordure physique. Une fois encore, l’opérateur [] et les fonctions de voisinage sont utilisés
afin de calculer, le schéma de reconstuction, les flux numériques, le schéma numérique et
enfin les frottements appliqués à l’écoulement du fluide. Notons, de nouveau, qu’il s’agit
d’une simplification de la simulation. En effet, le calcul complet de la reconstruction
hydrostatique est nécessaire avant le calcul des flux numériques, par exemple. Il n’est
donc en théorie pas possible de calculer les deux informations pour un même élément du
maillage dans la même boucle. Comme nous l’avons évoqué précédemment, suivant le code
de la simulation, plusieurs applicateurs peuvent être appelés dans la fonction principale.94 Chapitre 4. SkelGIS pour des maillages réguliers à deux dimensions
Algorithme 8 : Opération séquentielle permettant de décrire le calcul des schémas
numériques des équations de Saint Venant.
Data : {DMatrix}
Result : Modification de {DMatrix}
ItB := itérateur de début sur les bordures physiques
endItB := itérateur de fin sur les bordures physiques
while ItB≤endItB do
Application des conditions limites avec : h[ItB], u[ItB], v[ItB],
h.getRight(ItB,1), v.getX(ItB,2) ...
ItB++
end
It= itérateur de début sur les éléments du maillage
endIt := itérateur de fin sur les éléments du maillage
while It≤endIt do
Calculs avec h[It], u[It], v[It], h.getRight(It,1), v.getX(It,2) ...
Calcul de la reconstruction hydrostatique
Calcul des flux numériques
Calcul du schéma numérique
Calcul des frottements
It++
end
Chaque applicateur effectuera les boucles et les calculs dont il est responsable. Ces choix
d’implémentation sont à la charge de l’utilisateur tout comme s’il codait un algorithme
en séquentiel.
4.3.4 Résultats
Nous allons maintenant présenter les résultats obtenus sur la simulation des équations
de Saint Venant (4.10) décrites précédemment. Ces expériences sont basées sur
deux implémentations parallèles du logiciel FullSWOF. Ces deux implémentations ont
été effectuées de façon simultanée pendant le projet CEMRACS 2012 (Centre d’Eté Mathématique
de Recherche Avancée en Calcul Scientifique) et dans une durée limitée d’environ
trois semaines. La première est une version MPI, que nous appellerons FS_MPI, et
la deuxième la version SkelGIS, que nous appellerons FS_SK. Ces deux implémentations
sont basées sur une même version séquentielle du logiciel FullSWOF2D. FS_MPI a été
implémenté par un ingénieur en mathématiques appliquées ayant les connaissances de
base sur MPI, la version FS_SK, après une courte période de formation sur les concepts
de SkelGIS, a été implémentée par un numéricien. FS_MPI a été implémenté de façon
standard en MPI, en utilisant une décomposition de domaine, une topologie cartésienne
ainsi que des types dérivés. Cette version MPI part du même code séquentiel que l’implémentation
SkelGIS, et tout comme pour l’équation de la chaleur, les codes séquentiels
de calculs ne sont ni modifiés ni optimisés dans ces versions parallèles. Seules les struc-4.3. Résolution numérique des équations de Saint Venant 95
tures de données sont ré-implémentées pour être distribuées sur les processeurs. Le code
séquentiel de calcul n’a donc pas été modifié ou amélioré, les deux versions parallèles du
code sont donc comparables. En revanche, il ne s’agit pas des versions parallèles les plus
efficaces possibles pour cette simulation. Nous ne proclamons d’ailleurs pas que SkelGIS
soit aussi efficace qu’un code parallèle spécifiquement optimisé pour les équations de Saint
Venant. L’objectif du modèle SIPSim, et donc de SkelGIS, est de permettre de coder en
séquentiel un programme qui sera parallèle. L’efficacité du programme dépendra donc de
l’efficacité du code séquentiel en lui-même.
Les expériences menées et présentées ici sont de deux types. Tout d’abord FS_MPI
et FS_SK sont comparés en terme de performances sur les nœuds thin nodes du supercalculateur
TGCC-Curie du CEA, vingtième dans le classement top500 de novembre
2013. Son architecture matérielle est détaillée dans la table 4.5. Chaque expérience a
été effectuée quatre fois et moyennée. L’écart type noté sur l’ensemble des expériences
n’a pas excédé 2%. Par la suite, les métriques de Halstead [65], déjà présentées dans la
partie 2.5, sont utilisées afin de comparer les deux implémentations en terme de difficulté
de codage.
Calculateur TGCC Curie
Processeur 2×SandyBridge
(2.7 GHz)
Cœurs/nœud 16
Mémoire/nœud 64 GB
Compilateur [-O3] Bullxmpi
Réseau Infiniband
Table 4.5 – Spécifications matérielles des nœuds thin du TGCC-Curie
4.3.4.1 Performances
Nous allons tout d’abord comparer en terme de performances FS_MPI et FS_SK qui
sont deux versions parallèles comparables car basées sur le même code séquentiel. Quatre
expériences ont été menées pour évaluer les performances de ces simulations parallèles
et sont décrites dans la table 4.6. Ces expériences font varier la taille du domaine (expé-
riences 1, 3 et 4) ou le nombre d’itérations en temps (expériences 1 et 2). L’ensemble des
temps d’exécution obtenus (en secondes) sont présentés dans la table 4.7. Une représentation
graphique de ces résultats est également proposée dans les figures 4.12, 4.13, 4.14
et 4.15.
Pour l’ensemble des expériences effectuées nous pouvons observer des similarités. Tout
d’abord, nous pouvons noter que les temps d’exécution obtenus pour FS_SK sont, hormis
les valeurs en rouge, meilleurs que pour FS_MPI. Etant donné que la même version
séquentielle de code a été utilisée au départ, il s’agit d’une remarque intéressante pour la
solution SkelGIS. En effet, cette performance provient de l’objet DMatrix. Cet objet est
le seul objet qui n’est pas utilisé dans la version séquentielle, et qui peut être producteur96 Chapitre 4. SkelGIS pour des maillages réguliers à deux dimensions
Taille du maillage Nombre d’itérations
Expérience 1 5k × 5k 5k
Expérience 2 5k × 5k 20k
Expérience 3 10k × 10k 5k
Expérience 4 20k × 20k 5k
Table 4.6 – Expériences de performance sur FS_MPI et FS_SK.
Cœurs Exp1 (sec) Exp2 (sec) Exp3 (sec) Exp4 (sec)
MPI SkelGIS MPI SkelGIS MPI SkelGIS MPI SkelGIS
32 4868.43 3494.92
64 2317.7 1780.4 9353.47 7391.96
128 1154.25 898.219 4578.28 3768.54 45588 31332.9
256 578.65 460.715 2282.53 1988.8 22089.9 17385.6 90974,2 68017.1
512 277.39 284.585 1118.01 1117.09 11299.4 9436.11 45487.1 35112.2
1024 144.26 155.85 557.621 602.66 5739.52 5127.93 22299.1 19821.3
2048 67.49 103.363 273.785 407.73 2930.48 3196.71 11867.4 10986.8
Table 4.7 – Temps d’exécution (en secondes) obtenus pour l’ensemble des expériences sur
FS_MPI et FS_SK.
d’une amélioration de performances (et non de surcoûts) de la version FS_SK. Dans la
version FS_SK, l’utilisateur n’a plus à se soucier de coder ses propres structures de données
et leur accès efficace. Ce travail est confié à la bibliothèque SkelGIS, ce qui simplifie
tout d’abord le code, et ce qui permet d’obtenir une structure optimisée “gratuitement”,
sans aucun effort. Ce constat a d’ailleurs également été fait pour l’équation de la chaleur.
Coeurs (log2)
Temps d'exécution (log2)
SkelGIS
32 64 128 256 512 1024 2048
11
9
7
Figure 4.12 – Logarithme des temps d’exécution de l’expérience 1 pour FS_MPI et FS_SK.
Cependant, nous pouvons également noter que la pente des courbes des fi-
gures 4.12, 4.13, 4.14 et 4.15 est plus accentuée et donc meilleure pour FS_MPI que4.3. Résolution numérique des équations de Saint Venant 97
Coeurs (log2)
Temps d'exécution (log2)
MPI
SkelGIS
64 128 256 512 1024 2048
13 11
9
Figure 4.13 – Logarithme des temps d’exécution de l’expérience 2 pour FS_MPI et FS_SK.
Coeurs (log2)
Temps d'exécution (log2)
MPI
SkelGIS
128 256 512 1024 2048
15 13
Figure 4.14 – Logarithme des temps d’exécution de l’expérience 3 pour FS_MPI et FS_SK.
Coeurs (log2)
Temps d'exécution (log2)
MPI
SkelGIS
256 512 1024 2048
16 15
Figure 4.15 – Logarithme des temps d’exécution de l’expérience 4 pour FS_MPI et FS_SK.
pour FS_SK. En effet, ces figures représentent les temps d’exécution sur une échelle logarithmique.
Cette représentation a la particularité de permettre de comparer les temps
d’exécution mais également de connaître la linéarité du speedup de la simulation. Si
le temps d’exécution présenté est linéaire, le speedup le sera également, et la pente du98 Chapitre 4. SkelGIS pour des maillages réguliers à deux dimensions
temps d’exécution représente également la pente du speedup. Ainsi, il semble que pour
l’ensemble des expériences, le speedup de FS_MPI soit légèrement meilleur que celui de
FS_SK. Ce phénomène traduit, à l’inverse, les surcoûts de la solution SkelGIS, comme
cela a été noté pour l’équation de la chaleur également. En effet, si l’objet DMatrix peut,
de son côté, être à l’origine d’un gain de performances, du simple fait que la structure
de données qui y est implémentée est plus efficace, les autres objets SkelGIS tels que
les applicateurs et les itérateurs provoquent des appels supplémentaires et donc des surcoûts.
Notons que ces surcoûts sont plus accentués sur les expériences 1 et 2, ce qui
montre qu’il y a plus d’impact sur le nombre d’itérations en temps que sur la taille du
domaine à traiter. Ce phénomène s’explique assez bien. À chaque itération de temps, des
applicateurs sont appelés. Comme cela a déjà été expliqué, les applicateurs procèdent,
tout d’abord, aux communications nécessaires pour les calculs, puis, appellent l’opération
de l’utilisateur. Cette opération va ensuite créer des itérateurs et parcourir le maillage.
Si le nombre d’itérations en temps augmente, les surcoûts liés aux applicateurs et aux
itérateurs sont multipliés, alors que si la taille du domaine augmente, seul le parcours
des éléments du maillage est plus long mais les surcoûts, eux, restent identiques.
Pour conclure sur les performances de SkelGIS, il semble tout d’abord évident que,
partant d’un code séquentiel commun, SkelGIS obtient de très bonnes performances aussi
bien en temps d’exécution qu’en passage à l’échelle. SkelGIS offre des perspectives très
intéressantes par le biais de l’objet DMatrix. De façon plus générale, la méthode SIPSim
offre des performances intéressantes grâce à son composant DDS. En effet, dans le cas d’un
maillage cartésien, la structure de données mise en place reste très simple. Dans le cas
de maillages plus complexes, ce composant peut se montrer primordial comme cela sera
illustré dans le chapitre 5. Si SkelGIS provoque malgré tout des surcoûts qui peuvent nuire
à la linéarité de ses speedup, SkelGIS reste tout de même une solution de parallélisme
implicite très efficace sur les maillages cartésiens et qui propose des performances proches
d’une version MPI comparable.
4.3.4.2 Effort de programmation
L’approche SIPSim vise à proposer des solutions de parallélisme implicite pour les
simulations scientifiques. Pour cette raison, SkelGIS se doit d’être une solution simple
d’utilisation. Nous allons donc présenter les résultats qui ont été obtenus en terme de
difficulté de codage, toujours en comparant FS_MPI et FS_SK. Pour ce faire, les mé-
triques de Halstead [65] ont de nouveau été mesurées pour ces deux versions parallèles
de FullSWOF. Les résultats obtenus sont présentés dans la table 4.8.
Nous pouvons tout d’abord observer, dans ces résultats, que l’effort de programmation
E à fournir est environ vingt fois moins important pour FS_SK que pour FS_MPI.
Ce résultat montre donc que l’ambition de SIPSim, pour proposer des solutions de parallélisme
implicite simples, est atteinte. Rappelons que le résultat de l’effort de programmation
dans les métriques de Halstead est égal à la multiplication du volume du
programme V par la difficulté de codage du programme D. Nous pouvons observer dans
ce résultat que le volume de code V produit dans FS_SK est environ cinq fois moindre
que dans FS_MPI et que la difficulté D est environ quatre fois moindre dans FS_SK4.4. Conclusion 99
Métriques MPI SkelGIS Gain %
N1 7895 2673 66
N2 45147 8507 81
η1 414 297 28.3
η2 414 353 14.7
V 537.1K 104.5K 80.5
D 13274 3576 73
E 7130M 373M 94.7
Table 4.8 – Métriques de Halstead mesurées pour FS_MPI et FS_SK.
que dans FS_MPI. Ce résultat est intéressant puisqu’il montre deux aspects différents de
simplicité d’utilisation dans SkelGIS. Tout d’abord, le volume du programme parallèle de
FullSWOF écrit avec SkelGIS est cinq fois moins important que le volume du programme
MPI. Nous avons d’ores et déjà expliqué ce résultat, il est dû à l’utilisation de l’objet
DMatrix. En effet, l’utilisation de cet objet permet de s’abstraire de la programmation
d’une structure de données dans le code utilisateur, cette structure de données étant entièrement
gérée par SkelGIS. De plus, la répartition de cette structure de données sur les
différents processeurs est elle aussi entièrement implicite ce qui allège encore davantage
le volume du code final. Le second point de simplicité de SkelGIS, illustré par ces résultats,
est qu’il est quatre fois plus difficile de coder FS_MPI que FS_SK. Cette métrique
est fortement liée au nombre total et distinct d’opérateurs et d’opérandes dans les deux
implémentations. Une solution SIPSim ne fait appel qu’à quatre composants principaux
et délègue dans ces quatre composants les structures de données, les décompositions de
domaine et les communications MPI. Pour cette raison la complexité du code en terme
de nombre de variables et d’appels de fonctions est moins importante dans FS_SK.
Pour finir, et comme cela a déjà été abordé dans l’état de l’art, les métriques de
Halstead ne s’intéressent qu’à des concepts de programmation séquentiels. Ainsi, l’appel
à une fonction MPI aura le même coût que l’appel à une fonction classique d’un code
séquentiel. Aucune métrique existante ne tient compte de la difficulté des concepts parallèles
et de la difficulté de penser le programme en parallèle. Il est très difficile de mettre
en œuvre de telles métriques, toutefois il semble évident, dans ce cas, que la véritable
difficulté de programmation D de FS_MPI soit supérieure à celle trouvée par les mé-
triques de Halstead. SkelGIS de son côté, ne fait appel à aucune notion de parallélisme
et est aussi simple à mettre en œuvre, conceptuellement, qu’un code séquentiel.
4.4 Conclusion
Dans ce chapitre nous avons décrit l’implémentation de SkelGIS dans le cas particulier
des maillages cartésiens à deux dimensions. Nous avons tout d’abord détaillé l’implémentation
de cette solution en suivant les composants du modèle SIPSim, puis nous avons100 Chapitre 4. SkelGIS pour des maillages réguliers à deux dimensions
exposé deux cas d’application réels. Le premier sur la résolution de l’équation de la chaleur,
et le deuxième sur la résolution des équations de Saint Venant, en suivant la méthode
appliquée dans le logiciel FullSWOF2D. Un ensemble de résultats a été présenté, tout
d’abord sur les performances de la solution, mais aussi sur l’effort de programmation à
fournir pour utiliser SkelGIS. Ces résultats ont été comparés à ceux d’une implémentation
MPI implémentée à partir d’un même code séquentiel. Le modèle SIPSim, et donc la
bibliothèque SkelGIS, laissant à la charge de l’utilisateur le code séquentiel qui décrit les
schémas numériques à calculer, la performance du code final dépend également du code
séquentiel implémenté. Il était donc important de partir pour ces deux implémenetations
d’une même version séquentielle sans y ajouter d’optimisations séquentielles particulières
mais en proposant toutefois une implémentation MPI efficace. Les résultats obtenus sont
très intéressants et montrent qu’il est possible de proposer une implémentation du modèle
SIPSim efficace, d’autant plus que toutes les implémentations possibles n’ont pas
été mises en place, comme par exemple l’utilisation des registres de vectorisation. Cette
première implémentation de SkelGIS a donc illustré la viabilité du modèle SIPSim sur
des maillages cartésiens à deux dimensions.
Il est donc possible, grâce au modèle SIPSim d’implémenter des solutions de parallé-
lisme implicite efficace pour les maillages de type cartésiens. Une extension de ce travail
peut d’ailleurs proposer une solution pour des maillages cartésiens à n dimensions. De
plus, grâce aux travaux OP2 [59, 60, 94] et Liszt [50], nous savons que les concepts du
modèle SIPSim peuvent s’appliquer au cas des maillages non-structurés. En effet, OP2
et Liszt proposent des modèles de programmation parallèle implicite proches de ceux
proposés par l’approche SIPSim. Le reste de cette thèse propose une implémentation du
modèle SIPSim pour un cas d’application qui n’a, à notre connaissance, jamais été traité
dans des solutions de parallélisme implicite : les simulations sur des réseaux.5
SkelGIS pour des simulations
sur réseaux
Sommaire
6.1 Bilan . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 154
6.2 Perspectives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 156
101102 Chapitre 5. SkelGIS pour des simulations sur réseaux
Le chapitre précédent a présenté l’implémentation de SkelGIS pour les simulations
sur des maillages cartésiens à deux dimensions. Dans ce chapitre est abordé un cas plus
complexe d’implémentation de la méthode SIPSim. La bibliothèque SkelGIS a, en effet,
été implémentée dans le cas de simulations sur des réseaux pouvant être représentés sous
forme de graphes dirigés acycliques (DAG). Un réseau n’est pas considéré comme un
maillage, même s’il peut s’y apparenter par certains côtés. Dans ce chapitre sera tout
d’abord décrite la notion de réseau afin de comprendre pourquoi la méthode SIPSim peut
être appliquée à ce type de simulations. Par la suite, l’implémentation de SkelGIS pour
les réseaux sera détaillée. Cette implémentation, plus complexe que le cas des maillages
cartésiens à deux dimensions, nécessitera une partie supplémentaire décrivant avec précision
l’implémentation de la structure de données distribuée. Un cas d’application réel sera
ensuite présenté, afin d’évaluer les performances de SkelGIS. Il s’agit d’une simulation
d’écoulement du sang dans le réseau artériel. L’implémentation de cette simulation avec
SkelGIS sera comparée avec une version OpenMP, et sera évaluée sur différents types de
clusters. Enfin, des travaux plus récents seront présentés sur le problème de partitionnement
des réseaux. Nous étudierons l’implémentation actuelle de partitionnement dans
SkelGIS, puis deux méthodes plus proches du véritable problème de partitionnement des
réseaux.
5.1 Les réseaux
Afin de faciliter la compréhension de ce chapitre nous allons définir ce qui est appelé
un réseau et les caractéristiques qui en découlent. Cette notion est utilisée dans diverses
simulations, toutefois, malgré son utilisation fréquente, les détails sur ce qu’est exactement
appelé un réseau sont peu abordés dans la littérature. Nous allons illustrer ici qu’un
réseau peut être assimilé, par certains côtés, à un maillage. Toutefois un réseau n’est pas
un maillage pour plusieurs raisons qui seront présentées.
Tout d’abord, il est évident qu’un réseau, en terme de simulation, est une structure
permettant de simuler des phénomènes réels de réseaux tels que les réseaux routiers,
sanguins, fluviaux, pétrolifères etc. Un réseau permet de discrétiser le domaine en espace
en deux types d’éléments différents : les nœuds et les arêtes. Dans le cas d’une simulation
d’écoulement du sang dans le réseau artériel, par exemple, les arêtes du graphe
sont assimilées aux artères, et les nœuds du graphe aux points de rencontre de plusieurs
artères, aussi appelés conjonctions. Les points de rencontre des artères n’ayant pas tous
la même connectivité, un réseau est une structure irrégulière. Cette discrétisation peut
être apparentée à un graphe dirigé ou non, avec ou sans cycles et projeté dans R
2 ou
R
3
. Les deux types d’éléments sont donc les nœuds et les arêtes et à chacun de ces deux
types pourront être appliqués deux discrétisations et deux schémas numériques différents
(figure 5.1). Un réseau permet donc de résoudre des problèmes représentant deux phé-
nomènes physiques différents mais liés entre eux. La notion de réseau rappelle alors les
maillages avancés par blocs ou hybrides, qui ont été évoqués dans la section 2.2.2.2 de
cette thèse.
Un réseau peut être assimilé à un maillage pour deux raisons :5.1. Les réseaux 103
Figure 5.1 – Illustration d’un réseau à gauche et d’un exemple de simulation multi-physique à
droite avec deux types de discrétisation. Les nœuds subissent une discrétisation cartésienne de
l’espace et les arêtes subissent une discrétisation non-structurée de l’espace.
— La construction d’un réseau consiste en la dicrétisation du domaine en deux types
d’éléments.
— Un réseau représente une connectivité entre ses éléments sous la forme d’un
graphe.
Pour ces deux raisons, certaines ressemblances avec un maillage, en particulier avec un
maillage irrégulier, peuvent être trouvées et utilisées pour l’implémentation. En revanche,
un réseau n’est pas un maillage pour deux autres raisons :
— Le graphe formé par un réseau ne représente pas des faces ou des cellules, et les
calculs appliqués ne porteront pas sur des cellules, contrairement aux maillages.
— Le graphe ne forme pas l’objet simulé mais le représente avec deux types d’élé-
ments différents, potentiellement très grands, sur lesquels seront effectués des calculs
qui peuvent à nouveau discrétiser l’espace.
Afin de rendre plus claire la différence entre un maillage et un réseau nous allons
essayer d’en donner des définitions par l’intermédiaire de la théorie des graphes. Nous
avons d’ores et déjà vu qu’un maillage est un graphe connexe, sans isthme dont la planarité
est systématique dans R
2 mais pas dans R
3
. Un réseau est un graphe connexe
mais qui peut contenir des isthmes. Autrement dit, les nœuds d’un réseau ne forment
pas des faces, ou cycles élémentaires. De plus un réseau, même s’il est plaqué dans R
2
,
n’est pas nécessairement planaire contrairement à un maillage. Prenons deux exemples
simples qui montrent qu’un réseau n’est pas obligatoirement un graphe planaire, même
sur R
2
. Dans un réseau représentant les fleuves et rivières, par exemple, il arrive qu’il
existe des rivières souterraines avec un point d’entrée et un point de sortie en surface,
comme c’est le cas pour la résurgence de la Loire, appelée le Loiret, par exemple. Par
conséquent les arêtes, qui représentent les rivières et fleuves peuvent se croiser sans pour
autant qu’un point de conjonction ne soit présent à cette intersection. La même remarque
peut être faite sur un réseau routier dans lequel il y aurait des tunnels souterrains, ce qui
superposerait plusieurs routes qui ne se rencontrent pas. Un réseau est donc un graphe
connexe quelconque. La figure 5.2(a) illustre un graphe planaire qui ne forme pas un104 Chapitre 5. SkelGIS pour des simulations sur réseaux
maillage mais un réseau, et la figure 5.2(b) illustre un réseau où des arêtes peuvent se
croiser sans l’existence d’un nœud à la conjonction de ces arêtes.
(a) Graphe planaire qui ne repré-
sente pas un maillage.
(b) Graphe connexe quelconque représentant
un réseau.
Figure 5.2 – Maillages et réseaux.
Dans une simulation sur les réseaux deux discrétisations du domaine peuvent être
effectuées. La première pour construire le réseau et la deuxième sur chaque élément du
réseau, c’est-à-dire l’ensemble des nœuds et des arêtes. De cette façon il est possible
d’appliquer des schémas numériques à l’ensemble du domaine tout en définissant deux
comportements différents lors de la simulation. Dans un réseau deux types de schémas
numériques différents peuvent donc être appliqués, un à chaque type d’élément, les nœuds
et les arêtes. Ces deux schémas numériques ont la particularité d’être liés dans une même
itération de temps, c’est-à-dire que l’un des deux impacte le résultat de l’autre. Par
conséquent, dans une simulation sur les réseaux, même si chaque schéma numérique est
explicite et ne crée pas de dépendance parmi les éléments d’une même itération de temps
(comme c’est le cas dans l’équation (2.3)), une dépendance peut être créée entre les deux
schémas numériques. Il en résulte, soit l’obtention de nouveaux schémas numériques
explicites, soit plus généralement l’obtention de nouveaux schémas numériques implicites
(équation (2.4)). Si un ou plusieurs schémas implicites sont obtenus, suite à la mise en
réseau de chaque schéma explicite, l’un des deux types d’éléments du réseau devra être
calculé avant l’autre et son résultat impactera le deuxième dans une même itération de
temps. Ainsi si l’on note T1 et T2 les arêtes et les nœuds du réseau, ou vice-versa, une
simulation sur un réseau sera typiquement constituée de quatre étapes :
1. communication des éléments T1 à t − 1,
2. calcul des éléments T2 à t,
3. communication des éléments T2 à t − 1 ou/et t,
4. calcul des éléments T1 à t.5.1. Les réseaux 105
L’étape 3 est l’étape qui caractérise le fait que les schémas numériques sont explicites ou
implicites. En effet, si l’étape 4, pour être calculée, a besoin des éléments T2 uniquement
à l’instant t−1, nous considérons les schémas comme explicites. Si, en revanche, l’étape 4
nécessite les éléments T2 à l’itération t alors les schémas sont implicites. Notons que si les
schémas numériques sont explicites alors les étapes peuvent être organisées différemment :
1. communication des éléments T1 et T2 à t − 1,
2. calcul des éléments T1 et T2 à t,
La nature des schémas numériques (explicite ou implicite) n’est pas le seul élément
impacté par la liaison des schémas numériques d’un réseau. En effet, la notion de voisinage
N(x) (ou stencil) est elle aussi modifiée. Dans les schémas numériques d’un réseau, chaque
schéma numérique (implicite ou non) possède un voisinage défini par N(x) = Nin(x) ∪
Nout(x). Nin(x) représente le N(x) que l’on peut trouver dans une simulation classique
définissant un seul maillage et un seul schéma numérique, il s’agit des éléments voisins
de x dans le maillage local. Nout(x) représente le voisinage issu du réseau. En effet, dans
un réseau, comme illustré dans la figure 5.1, les différents maillages, et donc les différents
schémas numériques sont liés entre eux. Cette liaison se traduit par Nout(x). Notons
qu’un élément interne au maillage local n’est pas concerné par la liaison avec un autre
maillage et alors Nout(x) = ∅. La définition du voisinage Nout d’un réseau sera étudiée
en détails dans la partie 5.2.4.2, et est illustrée dans la figure 5.3.
Enfin une dernière caractéristique des réseaux doit être abordée dans cette partie.
Un maillage est concerné par ce qui est appelé une bordure physique. Comme il l’a
été expliqué précédemment, lorsqu’une simulation est effectuée, l’espace doit être borné
alors que le phénomène réel ne l’est pas. Pour cette raison des conditions limites sont
ajoutées aux EDP et permettent de simuler de façon plus réaliste ce qui se passe aux
bords du domaine. Un réseau, tout comme un maillage, discrétise le domaine initial, il
existe donc également une notion de bordure physique dans un réseau. Dans un réseau
général, représenté par un graphe quelconque, dirigé ou non, les bordures physiques vont
être représentées par certains nœuds indiqués au préalable comme bordures physiques
du domaine.
Dans le reste de ce chapitre, la définition générale d’un réseau n’est pas utilisée. En
effet, nous nous intéressons ici à une sous-partie des réseaux qui peuvent être représentés
par un graphe dirigé acyclique, ou DAG. Dans ce cas, les nœuds représentant la bordure
physique du domaine sont les nœuds racines et les nœuds feuilles du DAG. Le reste de ce
chapitre s’intéresse également à des simulations où les schémas numériques appliqués aux
éléments du réseau sont définis dans une unique dimension. Ce travail donne donc une
première solution de parallélisme implicite pour les simulations sur les réseaux et étudie
sa faisabilité. A notre connaissance, aucun travail similaire n’existe à l’heure actuelle.106 Chapitre 5. SkelGIS pour des simulations sur réseaux
5.2 SIPSim pour les réseaux
Tout comme pour l’implémentation de SkelGIS pour les maillages cartésiens, nous allons
tout d’abord décrire l’implémentation des quatre composants de la méthode SIPSim
pour le cas des simulations sur les réseaux.
5.2.1 Structure de données distribuée
Tout comme pour l’implémentation de la méthode SIPSim pour les grilles carté-
siennes à deux dimensions, le premier composant de la méthode SIPSim à implémenter
est la structure de données distribuée (DDS) permettant de représenter le maillage et sa
connectivité. Comme il l’a été décrit précédemment, un réseau ne peut pas être considéré
comme un maillage, toutefois il s’en approche puisque, tout comme un maillage, il
consiste à discrétiser le domaine en éléments et à en représenter leur connectivité. Pour
cette raison la structure de données distribuée qui représente le réseau doit posséder les
mêmes caractéristiques qu’un maillage, et la méthode SIPSim s’applique donc parfaitement
à ce type de simulations. La structure de données doit donc garantir un accès
efficace aux éléments (nœuds et arêtes) et aux voisinages Nout. Ce DDS, là encore, va
être responsable de l’efficacité de la solution, et l’ensemble de l’implémentation de la
méthode SIPSim pour les réseaux repose sur ce premier composant. Rappelons que nous
nous intéressons ici à la sous classe des réseaux pouvant être représentés sous forme de
DAG. La DDS pour ces réseaux est nommée DDAG. L’implémentation de ce DDS est
complexe, et la section 5.3 décrira en détails cette implémentation. Bien évidemment, la
difficulté majeure se trouve dans le fait qu’un réseau est une structure irrégulière où la
connectivité est différente pour chaque élément, là où elle est régulière pour les grilles
cartésiennes.
Une structure de données irrégulière va donc tout d’abord poser un problème d’efficacité
pour accéder aux éléments et à leur voisinage. La résolution de ce premier problème
sera entièrement décrite dans la section 5.3. Mais outre cette difficulté, une structure irré-
gulière pose un autre problème, qui lui aussi impacte de façon significative l’efficacité de
la solution. En effet la méthode SIPSim implémente des solutions SPMD ce qui implique
une bonne décomposition de domaine pour obtenir de très bonnes performances. Dans
le cas des réseaux, cette décomposition de domaine va se traduire par un problème de
partitionnement de graphe. Le problème du partitionnement de graphe est un problème
connu et NP-complet, ce qui signifie que des heuristiques sont appliquées pour approcher
au mieux la solution idéale en un temps raisonnable. Le partitionnement de réseau
est particulier par rapport à un partitionnement de graphe classique où il est considéré
que des calculs sont faits soit sur les nœuds, soit sur les arêtes. Dans le cas d’un ré-
seau des calculs sont effectués sur les deux types d’éléments et impactent l’équilibrage
de charge. L’heuristique implémentée dans le prototype actuel de SkelGIS ainsi que deux
autres algorithmes seront étudiés dans la section 5.5. En effet, dans la version actuelle de
SkelGIS, une heuristique de rattachement des arêtes sœurs a été implémentée. Cette solution
propose des résultats raisonnablement bons, comme nous le verrons dans la partie
d’évaluation 5.4, toutefois il est probable qu’avec les deux autres solutions étudiées dans5.2. SIPSim pour les réseaux 107
la section 5.5, qui utilisent du partitionnement d’hypergraphe, les performances soient
meilleures.
Lors d’un partitionnement de graphe, et donc d’un partitionnement de réseau, la
solution idéale n’est que rarement trouvée et il persiste toujours un léger déséquilibre
dans la solution de partitionnement. Pour cette raison, un point essentiel pour améliorer
les performances de la solution est d’opérer un recouvrement des communications avec
les calculs locaux. En effet, dans un programme parallèle de type SPMD pour du calcul
stencil, chaque sous-domaine en espace appartenant à chaque processeur peut être dé-
composé en deux parties. Une première partie est dite locale-interne. Cette partie peut
être calculée sans aucun échange avec les autres processeurs. La deuxième partie localeexterne,
en revanche, nécessite des communications avec les autres processeurs afin de
connaître les valeurs au bord du domaine local. Dans un mécanisme de recouvrement des
communications par les calculs, le programme se découpe alors en quatre étapes :
1. les communications non bloquantes MPI sont initialisées
2. les calculs sur la partie locale-interne sont effectués
3. les communications non bloquantes MPI sont terminées
4. les calculs sur la partie locale-externe sont effectués
Ce mécanisme a été mis en place pour l’implémentation de la méthode SIPSim sur les
réseaux (SkelGIS pour les réseaux). Ce mécanisme est possible par l’optimisation de la
structure de données distribuée qui sera décrite dans la section 5.3.
5.2.2 Application de données
L’objet DDAG renferme donc une structure de données lourde et complexe, contrairement
à l’implémentation de l’objet DMatrix. Cette complexité est due au fait qu’il
est nécessaire, pour chaque élément de la structure, de retenir la connectivité qui lui
est propre. Chaque élément a, en effet, potentiellement une connectivité différente. Pour
cette raison, l’implémentation de SkelGIS pour les réseaux reste fidèle à la méthode SIPSim.
En effet, pour l’implémentation des DMatrix le choix a été fait d’embarquer le
composant DPMap dans le DDS lui-même, celui-ci étant léger. Dans le cas des réseaux,
le DDS DDAG étant beaucoup plus lourd, il sera instancié une seule fois pour chaque
type de réseau de la simulation, puis des objets plus légers se chargeront d’appliquer les
données sur ce DDS. Ces objets sont de deux types, un pour appliquer des données sur
les nœuds du réseau, et l’autre pour appliquer des données sur les arêtes du réseau. Ils
sont nommés respectivement DPMap_Nodes et DPMap_Edges. Ce sont ces objets qui
seront instanciés pour définir les quantités simulées dans les schémas numériques et ainsi
pour obtenir σ.
5.2.3 Applicateurs et opérations
Une fois le DDAG défini et les quantités à simuler instanciées, il faut appliquer les
schémas numériques séquentiels. L’utilisateur va alors, comme pour les DMatrix, définir
des opérations séquentielles qui vont représenter ses schémas numériques. Libre à lui de108 Chapitre 5. SkelGIS pour des simulations sur réseaux
définir autant d’opérations que nécessaire, sa seule contrainte est d’appliquer ses opérations
par le biais d’applicateurs. Il en existe deux à l’heure actuelle pour les réseaux, ils
s’appellent apply_list et apply_listi. Ce sont des procédures définies par :
apply_list : {DPM ap_Edges} × {DPM ap_Nodes} × Op (5.1)
apply_listi : {T1} × {T2} × Op1 × Op2 (5.2)
où {DPM ap_Edges}, {DPM ap_Nodes}, {T1} et {T2} sont des ensembles d’instances
des objets DPM ap_Edges et DPM ap_Nodes, et Op, Op1 et Op2 des opérations.
Le premier applicateur effectue tout d’abord les communications afin d’obtenir les valeurs
voisines, et non locales, nécessaires aux calculs des quantités (DPMap) indiquées en
argument. L’applicateur appelle ensuite l’opération Op de l’utilisateur. Ce premier applicateur
peut être particulièrement intéressant pour l’application de schémas numériques
explicites car il a la particularité d’offrir beaucoup de libertés à l’utilisateur. En effet,
l’utilisateur peut librement écrire une opération qui agira sur les nœuds comme sur les
arêtes, dans l’ordre souhaité. Les schémas numériques étant explicites, seules les communications
effectuées avant l’appel à l’opération sont nécessaires. Toutefois, ces opérations,
très générales, ne peuvent être mises en place pour des schémas numériques implicites,
puisqu’alors des communications sont nécessaires entre les phases de calcul. Cependant,
il est tout de même possible d’appliquer des schémas numériques implicites avec ce premier
applicateur. Dans ce cas, il sera nécessaire d’appeler l’applicateur à chaque phase
du calcul afin d’effectuer implicitement les échanges de données nécessaires. Il n’est alors
pas évident de définir l’utilisation de l’applicateur, ni de comprendre son utilisation. Pour
cette raison, un deuxième applicateur est proposé et permet d’effectuer des calculs implicites
en quatre étapes, comme présenté dans la partie 5.1. Cet applicateur commencera
par (1) effectuer une première phase de communication des éléments de type T1 (calculés
à l’itération t − 1), puis (2) appellera l’opération Op2 sur les éléments de type T2. Par
la suite, (3) une deuxième phase de communication aura lieu pour échanger les éléments
de type T2 venant d’être calculés ( à l’itération t donc), pour enfin (4) appeler l’opération
Op1 qui effectue les calculs sur les éléments T1. Notons, de nouveau, que T1 et T2
peuvent alors être associés indifféremment aux nœuds ou aux arêtes du réseau.
5.2.4 Interfaces de programmation
Enfin afin de pouvoir programmer une opération le dernier composant de la méthode
SIPSim doit être implémenté. Il s’agit des interfaces de programmation qui sont regroupées
en trois types précédemment décrits, les itérateurs, les accesseurs, et les fonctions
pour accéder aux voisinages des éléments.
5.2.4.1 Itérateurs
Les itérateurs permettent de se déplacer dans des données appliquées à un DDS. Dans
le cas des réseaux, les itérateurs permettent donc de se déplacer dans des instances des
objets DPMap_Nodes et DPMap_Edges. Il existe huit itérateurs pour les réseaux, trois
pour l’objet DPMap_Edges et cinq pour l’objet DPMap_Nodes.5.2. SIPSim pour les réseaux 109
Rappelons que, tout comme pour les DMatrix, l’ordre de parcours de l’ensemble des
nœuds ou de l’ensemble des arêtes du réseau n’a pas d’importance, et que la solution
obtenue sera la même si l’ordre de parcours est modifié. En effet, comme expliqué dans
la partie 5.1, le seul ordre de traitement peut venir des phases de calcul sur les nœuds
ou sur les arêtes. Ceci est dû au fait que les deux schémas numériques explicites (2.3)
de départ n’introduisent pas de dépendances entre les nœuds d’une même itération de
temps, ou entre les arêtes d’une même itération de temps.
Nous allons décrire les trois itérateurs communs aux objets DPMap_Nodes et DPMap_Edges.
Tout d’abord, un premier itérateur permet simplement de parcourir l’ensemble
des nœuds/arêtes du réseau et cet itérateur garantit que l’ensemble des nœuds/arêtes
est bien parcouru. Cet itérateur est ensuite divisé en deux itérateurs qui vont permettre
de parcourir les nœuds/arêtes locaux-internes et locaux-externes précédemment
décrits. Ces deux itérateurs seront utilisés pour effectuer le recouvrement des communications
MPI par les calculs locaux dans les applicateurs. Deux itérateurs supplémentaires
ont été implémentés afin de naviguer dans la bordure physique du domaine. Il a été décrit
dans la section 5.1 que les bordures physiques, dans le cas d’un réseau de type DAG,
étaient représentées par deux types particuliers de nœuds, les racines et les feuilles du
DAG. Un premier itérateur sur les bordures physiques permet donc de naviguer dans les
racines du DAG, et un deuxième dans les feuilles du DAG. Tout comme pour les DMatrix,
ces deux itérateurs sont très importants pour éviter des conditions inutiles dans
le code utilisateur afin de déterminer si le nœud courant est une racine, une feuille ou
un nœud interne. Avec l’ensemble de ces itérateurs, l’utilisateur peut naviguer dans la
bordure physique et dans les nœuds internes sans conditions dans le code. Notons que
l’implémentation de ces itérateurs est possible, là encore, grâce aux optimisations de la
DDS DDAG qui sera décrite dans la section 5.3.
5.2.4.2 Voisinages
La notion de voisinage est l’une des notions les plus importantes de la méthode
SIPSim puisqu’elle rend possible les calculs stencil et donc la résolution des EDP. Il
est important, tout d’abord, de définir quel voisinage Nout est nécessaire autour d’un
nœud et d’une arête du réseau pour qu’ils puissent être calculés. La figure 5.3 illustre le
voisinage nécessaire. Pour un nœud du réseau, le voisinage nécessaire peut être constitué
des arêtes entrantes et sortantes. Pour une arête du réseau, les informations nécessaires
sont les nœuds source et destination de l’arête. Par conséquent, les interfaces permettant
de connaître le voisinage d’un nœud sont constituées de deux fonctions pour obtenir une
liste des arêtes entrantes et sortantes, la première notée getInEdges, et la deuxième notée
getOutEdges. Ces fonctions retournent un vecteur d’itérateurs sur les arêtes entrantes et
sortantes. Quant aux interfaces pour le voisinage d’une arête il s’agit de deux fonctions,
l’une pour connaître le nœud source, notée getSrcNode, et l’autre pour connaître le nœud
destination, notée getDstNode.
Cependant ce voisinage peut être plus complexe dans certains cas. En effet, comme
nous l’avons évoqué précédemment, une simulation sur les réseaux est une sous-partie
des simulations multi-physiques. Cela signifie que potentiellement deux discrétisation110 Chapitre 5. SkelGIS pour des simulations sur réseaux
ARÊTES
ENTRANTES ET
SORTANTES
NOEUD
SOURCE
VOISINAGE DES NOEUDS VOISINAGE DES ARÊTES
NOEUD
DESTINATION
Figure 5.3 – Voisinage d’un nœud et d’une arête d’un réseau.
différentes peuvent être effectuées dans les nœuds et les arêtes afin de simuler deux
phénomènes liés ensemble par un réseau. SkelGIS se limite, comme nous l’avons vu,
aux discrétisations à une dimension dans les nœuds et les arêtes. SkelGIS offre donc la
possibilité de définir que chaque arête ou nœud contient un tableau et non un unique
élément. Cette notion est très importante pour pouvoir appliquer des schémas numériques
à une dimension sur les arêtes ou les nœuds. Bien entendu, cette notion pourrait être
étendue à un schéma numérique à deux dimensions et plus, mais le prototype actuel de
SkelGIS n’implémente pas ces fonctionnalités. Par exemple, dans une simulation sanguine
sur le réseau artériel, l’écoulement du sang dans une artère est simulé par des équations
d’écoulement des fluides. Par conséquent, il faut simuler l’écoulement dans une artère
par un maillage, que nous supposerons à une dimension. Dans ce cas il est important
de se demander ce que devient le voisinage nécessaire pour un nœud. Ce voisinage est
alors lié, tout comme pour les DMatrix, à l’ordre du schéma numérique appliqué dans
les arêtes du réseau. La figure 5.4 illustre ce voisinage dans le cas d’un schéma d’ordre
2. En effet, dans ce cas, le nœud source de l’arête n’a pas besoin de connaître l’ensemble
du maillage 1D géré par l’arête, mais uniquement ses deux premières valeurs. De même
le nœud destination n’a besoin de connaître que les deux dernières valeurs du maillage
de l’arête.
ordre=2
Figure 5.4 – Voisinage pour le cas particulier d’un maillage 1D dans les arêtes.
Cette notion avancée de voisinage est très importante afin de ne pas échanger des données
inutiles lors des communications MPI. En d’autres termes, il est très important, pour
l’efficacité du programme parallèle, que les communications MPI effectuées représentent
réellement le voisinage Nout nécessaire. Il serait en effet dommage de communiquer tout
le tableau deux fois pour chaque arête. Cette notion peut paraître compliquée, toutefois5.2. SIPSim pour les réseaux 111
nous allons voir dans la partie qui suit, que l’utilisation des spécialisations partielles de
templates simplifie l’utilisation de ces concepts.
5.2.5 Spécialisation partielle de template
Tout comme dans le cas des maillages cartésiens à deux dimensions, le mécanisme de
spécialisation de template a été utilisé pour SkelGIS dans son implémentation pour les
réseaux. Une fois encore, la détermination des bons paramètres de template permet d’éviter
des conditions coûteuses dans le code et permet d’éviter la mise en place du système
d’héritage virtuel du C++, coûteux à l’exécution. Nous avons vu pour l’objet DMatrix
que ses spécialisations étaient liées à la valeur de son ordre, ainsi qu’à la définition de sa
connectivité. Pour les réseaux, les paramètres sont également liés au voisinage nécessaire
(l’ordre de la simulation) mais également au type de données appliquées aux arêtes ou
aux nœuds du réseau.
Tout d’abord, contrairement à l’objet DMatrix de SkelGIS, l’objet DDAG ne concerne
que la définition du réseau et sa connectivité et non les données qui lui sont associées.
Pour cette raison, conformément à la méthode SIPSim, cet objet est peu manipulé par
l’utilisateur. Bien que l’objet DDAG soit responsable de l’efficacité de l’ensemble de
la solution, et que l’ensemble de l’implémentation repose sur lui, les spécialisations de
templates ne portent pas sur lui mais sur les données qui y sont appliquées, les DPMap.
De cette manière, les choix effectués pour les voisinages ne sont pas figés pour toutes les
données de la simulation, ce qui laisse plus de liberté à l’utilisateur. Comme nous l’avons
vu précédemment, certaines quantités à simuler peuvent en effet participer au schéma
explicite (2.3) par le calcul de σ(x, t − 1), mais pas par le calcul de σ(y, t − 1); y ∈ N(x).
Il est donc important pour l’efficacité de la solution de laisser la notion de voisinage au
niveau des quantités à simuler et non au niveau du réseau. La figure 5.5 donne la définition
des objets DPMap_Edges et DPMap_Nodes. Le premier paramètre de template, appelé
T, indique le type de données à appliquer sur le réseau. Le deuxième paramètre, appelé
node_access va donner des indications sur la participation de l’instance au calcul de
N(x).
template struct DPMap_Edges
template struct DPMap_Nodes
Figure 5.5 – Définition des objets DPMap_Edges et DPMap_Nodes.
Les deux paramètres T et node_access sont liés dans leur spécialisation. Deux cas
de spécialisation se présentent pour le paramètre T, et suivant ce cas la spécialisation
de node_access sera différente. Le paramètre T représente le type de données qui va
être appliqué au réseau. Ce peut être (1) soit un type de base du C++, comme int,
float, double etc., (2) soit un pointeur d’un type de base, autrement dit un tableau à
une dimension, comme int∗, float∗, double∗ etc.
1. Dans le premier cas, cela signifie que la donnée associée à chaque nœud ou chaque
arête du réseau ne contiendra qu’une unique valeur. Dans ce cas node_access pourra112 Chapitre 5. SkelGIS pour des simulations sur réseaux
prendre seulement deux valeurs, 0 ou 1. Si node_access = 0, alors cela signifie que
la quantité instanciée n’est pas concernée par les calculs du type N(x), et que cette
quantité est donc uniquement utilisée localement. Dans ce cas les échanges MPI
n’auront pas lieu sur cette quantité ce qui améliorera les performances. À l’inverse,
si node_access = 1, la quantité sera utilisée dans les calculs du type N(x) et les
échanges MPI seront effectués.
2. Dans le cas où T est un type pointeur, ce qui signifie que chaque nœud ou chaque
arête du réseau est associé à un tableau de valeurs, nous aurons, node_access ∈
J0, nK où n est le nombre d’éléments dans le tableau. Comme dans le cas précédent
si node_access = 0 alors la quantité à simuler n’interviendra pas dans les calculs
du type N(x) et les échanges MPI n’auront pas lieu. Si node_access > 0 alors la
valeur de node_access indique l’ordre du schéma numérique qui sera appliqué dans
les nœuds ou dans les arêtes, et indique donc l’échange nécessaire pour effectuer le
calcul. Notons que si node_access = 1, il s’agit d’un cas plus simple à gérer, une
spécialisation de ce cas est donc implémentée.
Les cinq spécialisations de templates nécessaires à l’objet DPMap_Edges, pour gérer ces
cas, sont présentées dans la figure 5.6.
template struct DPMap_Edges
template struct DPMap_Edges
template struct DPMap_Edges
template struct DPMap_Edges
template struct DPMap_Edges
template struct DPMap_Edges
Figure 5.6 – Spécialisations partielles de template pour l’objet DPMap_Edges
Les notions complexes de voisinage présentées dans la section précédente sont donc
entièrement gérées grâce à deux paramètres de template spécialisés. L’utilisateur n’a
qu’à se soucier du type d’éléments dans les nœuds et les arêtes et de l’ordre du schéma
numérique 1D appliqué, pour que l’ensemble soit optimisé en terme de communications
MPI.
5.3 Structure de données distribuée pour les réseaux
Dans la section précédente, le DDS DDAG pour les simulations sur les réseaux a été
présenté. Nous détaillons dans cette partie l’implémentation de cette structure de données
distribuée [44]. Comme toute DDS de la méthode SIPSim, cet objet est en grande
partie responsable de l’efficacité de la solution. Son implémentation dérive du format
Compressed Sparse Row (CSR) que nous allons tout d’abord décrire. L’adaptation et
la parallélisation du format CSR seront ensuite présentées pour enfin expliquer comment
s’articule l’implémentation générale de SkelGIS pour les réseaux autour de ce DDS.
Notons qu’une version parallèle du format CSR a déjà été proposée, et fait partie de5.3. Structure de données distribuée pour les réseaux 113
la bibliothèque PBGL [51, 62]. Toutefois, cette implémentation n’est pas spécifiquement
développée pour les simulations scientifiques basées sur des maillages. Notre implémentation
propose un certain nombre d’optimisations propres aux réseaux et aux simulations,
que nous allons décrire dans cette section.
5.3.1 Le format Compressed Sparse Row
La variation à trois tableaux du format Compressed Sparse Row (CSR) permet de
stocker des matrices creuses de façon relativement légère puisqu’il permet de ne stocker
que les éléments qui ne sont pas des zéros, aussi appelés des éléments non-nuls dans le
reste de ce travail. Ce format est donc constitué de trois tableaux dont voici la description :
— values contient les valeurs des éléments non-nuls qui sont stockés ligne après
ligne.
— columns est de même taille que le tableau précédent. L’élément i du tableau
columns contient l’index de colonne associé à l’élément i du tableau values.
— rowIndex est le dernier tableau du format dans lequel l’élément i contient l’index,
dans le tableau values, du premier élément non-nul de la ligne i de la matrice.
Notons qu’un premier élément factice, égal à zéro, est ajouté au début du tableau
rowIndex de telle sorte que la ligne i contient rowIndex[i+1]−rowIndex[i] éléments nonnuls.
Pour pouvoir accéder à un élément non-nul, avec le format CSR, en connaissant
ses index de ligne et de colonne (i, j), il faut trouver la valeur j entre les éléments
columns[rowIndex[i]] et columns[rowIndex[i + 1] − 1]. L’index k auquel est trouvée
la valeur j représente alors l’index dans le tableau values où se trouve l’élément nonnul
recherché. Pour cette raison, le format CSR est peu efficace pour manipuler, de
façon répétée, des accès aux éléments d’une matrice creuse avec les index de lignes et
de colonnes. Cependant, nous allons montrer que ce format peut être très efficace pour
stocker la connectivité d’un graphe.
Un graphe non-orienté est défini par G = (V, E) où V est un ensemble fini de nœuds
et E ⊆ V × V est un ensemble fini d’arêtes. La matrice Sp(G) associée au graphe G
représente la matrice d’adjacence du graphe G, comme illustré dans la figure 5.7. Dans
une matrice d’adjacence, chaque élément non-nul de la matrice représente une arête
e ∈ E du graphe G, et les index de ligne et de colonne représentent les deux nœuds aux
extrémités de l’arête. Notons donc que pour un graphe non-orienté, la matrice Sp(G) est
symétrique.
Dans un graphe G = (V, E), vi et vj ∈ V sont appelés des nœuds voisins si (vi
, vj ) ∈ E.
En d’autres termes, deux nœuds sont voisins si deux éléments non-nuls existent dans la
matrice Sp(G) aux emplacements (vi
, vj ) et (vj , vi). Pour tout v ∈ V, N(v) est l’ensemble
des nœuds voisins du nœud v. Le degré d’un nœud v ∈ V , noté deg(v), est le nombre
d’arêtes incidentes de v, ce qui signifie que deg(v) = |N(v)|. Dans la ligne v de la matrice
Sp(G), N(v) représente les index des colonnes où des éléments non-nuls sont présents.
Définition 2 Dans un graphe non-orienté G = (V, E) où V = {v0, ..., vn−1}, le degré
cumulé d’un nœud vi ∈ V est noté cdeg(vi) et est défini par cdeg(vi) = Pi
j=0 deg(vj ).114 Chapitre 5. SkelGIS pour des simulations sur réseaux
0
1 2
3 4
5 6 7
1
1
10 0 0 0 0 01
1 1
1
1
1 1 1
1
1
1
0 0 0 0 0
0 0 0 0 0 0 0
0 0 0 0 0 0 0
0 0 0 0
0 0 0 0 0 0 0
0 0 0 0 0 0 0
0 0 0 0 0 0 0
0 1 2 3 4 5 6 7
0
1
2
3
4
5
6
7
Figure 5.7 – Graphe non orienté G et sa matrice d’adjacence Sp(G).
Dans la matrice Sp(G), cdeg(vi) représente le nombre d’éléments non-nuls de la ligne
vi additionné au nombre d’éléments non-nuls des lignes précédentes. Il est donc possible
de représenter G avec deux tableaux :
— Le premier, de taille n + 1 = |V | + 1, et appelé cdeg, est défini par cdeg[i + 1] =
cdeg(vi), ∀i ∈ J0, nJ, où cdeg[0] = cdeg(v−1)
def = 0.
— Le deuxième, appelé N est de taille cdeg[n] = cdeg(vn−1) et est défini pour tout
vi ∈ V par, N(vi) = {vN[j]
|j ∈ [cdeg[i], cdeg[i + 1][}.
Cette représentation, en utilisant deux tableaux pour stocker un graphe G, correspond
en fait au format CSR pour la matrice Sp(G). En effet, les tableaux cdeg et N de G
correspondent aux tableaux rowIndex et columns de Sp(G). La figure 5.7 nous donne un
graphe et sa matrice d’adjacence. Dans ce graphe le nœud 0 a deux nœuds voisins, 1 et
2, nous avons alors la deuxième valeur du tableau cdeg égale à 2, et les deux premières
valeurs de N égales à 1 et 2. Le nœud 1 a ensuite trois voisins, la troisième valeur de
cdeg est alors cdeg[2] = 2 + 3 = 5. En procédant ainsi pour chaque nœud du graphe,
nous obtenons, cdeg = [0, 2, 5, 6, 7, 11, 12, 13, 14] et N = [1, 2, 0, 3, 4, 0, 1, 1, 5, 6, 7, 4, 4, 4].
Grâce à cette représentation du graphe avec deux tableaux, il est possible, par exemple,
d’accéder très facilement au voisinage du nœud 4. En effet, cdeg[4] = 7 et cdeg[5]−1 = 10
indiquent le premier et le dernier index de N où se trouvent les voisins du nœud 4. Les
nœuds voisins du nœud 4 sont donc les nœuds 1, 5, 6 et 7. De façon plus générale,
supposons que les données associées à chaque nœud soient stockées dans un troisième
tableau X tel que |X| = |V |. Alors, pour accéder aux valeurs voisines d’un nœud vi
, il
suffit d’accéder aux éléments N[cdeg[i]] à N[cdeg[i + 1] − 1] dans X.
Le format CSR, initialement fait pour stocker des matrices creuses, est donc un format
très intéressant pour stocker la connectivité d’un graphe et pour retrouver en O(1) les
voisins d’un nœud du graphe.5.3. Structure de données distribuée pour les réseaux 115
5.3.2 Format pour les DAG distribués
Comme nous l’avons indiqué dans la section 5.1, l’implémentation actuelle de SkelGIS
n’a été développée que pour le sous-cas des réseaux pouvant être représentés par des DAG
(par exemple celui de la figure 5.8). Cette partie va étudier l’adaptation du format CSR,
décrit dans la partie précédente, pour les DAG. Nous allons décrire comment ce format
a été optimisé pour le cas des simulations scientifiques, et comment il a été parallélisé.
Un graphe orienté G = (V, E) est un graphe pour lequel chaque arête e = (v1, v2) ∈ E
est dirigée de v1 vers v2, et où v1 et v2 sont respectivement appelés le nœud source et
le nœud destination de l’arête e. Un graphe orienté acyclique (DAG) est un graphe
G = (V, E) orienté tel que pour tout v ∈ V , il n’y a pas de chemin, en suivant les arêtes
successives, de v vers lui même.
Dans les simulations scientifiques sur les DAG, pour être calculé, un nœud peut
avoir besoin de ses arêtes entrantes et de ses arêtes sortantes. De son côté, pour être
calculée, une arête a uniquement besoin de son nœud source et de son nœud destination
(figure 5.3). Dans un DAG G = (V, E), pour un nœud v ∈ V et une arête e ∈ E, S(e)
est le nœud source de e et D(e) est le nœud destination de e. N
+
V
(v) décrit l’ensemble
des nœuds sortants de v ∈ V et est défini par N
+
V
(v) = {v
0
|(v, v0
) ∈ E}. N
+
E
(v) est
l’ensemble des arêtes sortantes de v ∈ V et est défini par N
+
E
(v) = {e ∈ E|S(e) = v}. De
façon symétrique, N
−
V
(v) et N
−
E
(v) sont définis comme les ensembles de nœuds et arêtes
entrantes de v ∈ V . Un nœud racine v ∈ V d’un DAG G vérifie que |N
−
E
(v)| = 0. À
l’inverse, un nœud feuille v ∈ V vérifie que |N
+
E
(v)| = 0.
La définition 2 doit alors être adaptée au cas des DAG pour les éléments entrants dans
un nœud et ceux sortants d’un nœud. Dans un DAG G = (V, E), où V = {v0, ..., vn−1},
pour un nœud vi ∈ V , cdeg+(vi) = Pi
j=0 |N
+
E
(vj )| définit le degré cumulé sortant pour
le nœud vi
, cdeg−(vi) = Pi
j=0 |N
−
E
(vj )| définit le degré cumulé entrant pour le nœud vi
.
Notons ici que les degrés cumulés ont la particularité d’être les mêmes pour les arêtes et
les nœuds d’un DAG. En effet, pour un nœud v ∈ V , le nombre de nœuds entrants est
égal au nombre d’arêtes entrantes. De même le nombre de nœuds sortants est égal au
nombre d’arêtes sortantes : ∀vj ∈ V , |N
+
E
(vj )| = |N
+
V
(vj )| et |N
−
E
(vj )| = |N
−
V
(vj )|.
Comme dans la partie précédente, les tableaux cdeg+ et cdeg−, de taille |V |+1 = n+1,
sont définis par cdeg+[i + 1] = cdeg+(vi) et cdeg−[i + 1] = cdeg−(vi), où cdeg+[0] =
cdeg−[0] = 0. Enfin il est également possible de définir les tableaux N
+
E
et N
−
E
de taille
cdeg+[n] et cdeg−[n] qui représentent les index de voisinage associés aux degrés cumulés.
Enfin, deux tableaux S et D de taille |E| représentent l’ensemble des nœuds sources et
destinations pour chaque arête de telle sorte que S[i] = S(ei) et D[i] = D(ei), ∀ei ∈ E.
La figure 5.8 représente un DAG simple que nous allons prendre comme exemple
pour cette nouvelle structure de données. Le nœud 0 n’a pas de voisin entrant mais a
deux voisins sortants. Par conséquent, la deuxième valeur de cdeg− est égale à 0, et la
deuxième valeur de cdeg+ est égale à 2. Les index des voisinages associés sont stockés
dans N
+
E
et N
−
E
. Nous obtenons les tableaux suivants :
cdeg+ = [0, 2, 4, 4, 4, 7, 7, 7, 7], cdeg− = [0, 0, 1, 2, 3, 4, 5, 6, 7]
N
+
E = [0, 1, 2, 3, 4, 5, 6], N −
E = [0, 1, 2, 3, 4, 5, 6]116 Chapitre 5. SkelGIS pour des simulations sur réseaux
S = [0, 0, 1, 1, 4, 4, 4], D = [1, 2, 3, 4, 5, 6, 7]
0
1 2
3 4
5 6 7
0 1
2 3
4 5 6
Figure 5.8 – Graphe orienté acyclique correspondant au graphe 5.7.
La structure de données ainsi obtenue est adaptée aux DAG. En revanche, cette
structure de données ne comporte aucune optimisation spécifique pour les simulations
scientifiques et n’est pas non plus encore une structure de données distribuée. Nous
allons décrire ces deux points dans le reste de cette partie. Notons que cette section
ne s’intéresse pas au partitionnement d’un graphe orienté acyclique, que nous verrons
dans la section 5.5, mais uniquement à la structure de données permettant de stocker les
informations locales à chaque processeur de façon efficace.
Une fois le partitionnement de graphe effectué, chaque processeur reçoit donc une
partie du graphe de départ avec ses index globaux. Étant donné que la structure de
données, vue précédemment, repose sur une indexation contiguë commençant à 0 pour les
tableaux, cela implique tout d’abord qu’une ré-indexation locale va être nécessaire pour
la version distribuée de la structure. La figure 5.9 montre un graphe global partitionné
pour quatre processeurs. Nous nous intéressons à la partie bleue de ce graphe global, qui
va être distribuée au processeur 1. Ce sous-graphe G1 = (V1, E1) possède |V1| = 8 nœuds
et |E1| = 7 arêtes. La partie bleue de ce DAG n’est toutefois pas la seule partie à laquelle
devra s’intéresser le processeur 1. En effet, pour gérer comme il se doit les voisinages des
nœuds et des arêtes, des informations supplémentaires, provenant d’autres processeurs,
seront nécessaires. La figure 5.10(a) montre les nœuds et arêtes dont le processeur 1 aura
besoin pour effectuer convenablement ses calculs. On y retrouve la partie bleue du graphe
global mais également des nœuds et arêtes additionnels en pointillés. Ces nœuds et ces
arêtes sont des données qui ne sont pas locales au processeur 1 mais dont le processeur
1 aura besoin pour effectuer ses calculs. Ces données devront donc être insérées dans
la structure pour permettre les calculs et être accessibles efficacement pour assurer de
bonnes performances.
Le premier point auquel s’intéresser pour distribuer la structure de données est donc5.3. Structure de données distribuée pour les réseaux 117
0
2
0
3
1
6
5
7
6 7
1
2
4
3
5
4
8
8
9
9
10
10
11
11
12
12
13
13
14
14
15
15
16
16 17
17
18
18
19
21
22
22
23
23
24
24
26 27
25
28
Figure 5.9 – DAG global partitionné pour quatre processeurs. Le processeur 1 récupère la partie
bleue de ce partitionnement.
de ré-indexer le graphe local de chaque processeur afin de pouvoir utiliser les tableaux
présentés précédemment. La ré-indexation des arêtes est la plus simple. Elle peut être
quelconque. Dans la figure 5.10 la ré-indexation consiste à numéroter les arêtes de haut en
bas et de gauche à droite. La ré-indexation des nœuds locaux, quant à elle, a été pensée
afin d’optimiser l’utilisation des lignes de cache et de limiter les défauts de cache. Cette
optimisation est liée au fait que l’on cherche à résoudre des simulations scientifiques et à
leurs caractéristiques particulières. L’optimisation de la ré-indexation des nœuds consiste
à ordonner les nœuds par classe de façon à pouvoir se déplacer dans la classe de nœuds
de façon contiguë en mémoire. Quatre classes de nœuds ont été identifiées. Tout d’abord,
il a été présenté dans la section 5.2 l’intérêt d’un recouvrement des communications par118 Chapitre 5. SkelGIS pour des simulations sur réseaux
0
2
7
3
7
8
10 11
15 16 17 18
6
0 1
8
2
9
3 9 10
12
4
13
5
14
6
11 12 13 14 15
(a) Partie du graphe global qui intéresse le processeur
1 pour gérer ses données locales et les
voisinages nécessaires.
8
2
7
9
3
8
10 11
12 13 14 15
0
0 1
7
2
1
3 9 10
6
4
4
5
5
6
11 12 13 14 15
(b) Ré-indexation du sous-graphe géré par le
processeur 1.
Figure 5.10 – Sous-graphe géré par le processeur 1 avant et après ré-indexation.
les calculs, afin de limiter le déséquilibrage de charge d’un partitionnement du domaine
pour les différents processeurs. Cette technique connue du parallélisme repose sur le
fait que les communications, nécessaires pour calculer les éléments au bord du domaine
local, sont recouvertes par les calculs purement locaux qui peuvent d’ores et déjà être
effectués. Pour cette raison les deux premières classes de nœuds de la ré-indexation sont
les nœuds locaux-internes du domaine et les nœuds locaux-externes du domaine. Les
premiers ne nécessitent pas de communications avec les autres processeurs pour être
calculés, à l’inverse des seconds. Il a également été présenté dans les sections 2.2 et
5.1 que les simulations scientifiques, pour être plus réalistes sur le phénomène simulé,
opèrent des comportements particuliers pour les éléments de la bordure physique du
domaine global étudié. De plus, dans un réseau de type DAG, les nœuds de la bordure
physique ne sont autres que les feuilles et les racines du DAG. Les deux autres classes de
nœuds concernées par la ré-indexation sont donc les feuilles, puis les racines. Le passage
de la figure 5.10(a) à la figure 5.10(b) illustre cette ré-indexation des nœuds locaux (en
traits continus) en quatre classes, et la figure 5.11 résume ce système de ré-indexation.
Cette ré-indexation des éléments locaux à un processeur possède deux avantages en
terme de performances. Tout d’abord, elle favorise la bonne utilisation des lignes de mé-
moire cache. En effet, dans une simulation scientifique sur les réseaux, toutes les racines
vont être explorées afin de décrire leur comportement, de même pour les feuilles. Ces5.3. Structure de données distribuée pour les réseaux 119
Noeuds
locaux-internes
Noeuds
locaux-externes
RacinesFeuilles Noeuds en pointillés
sur la Figure 5.12
Arêtes en pointillés
sur la Figure 5.12
Arêtes locales
Figure 5.11 – Parallélisation de la structure et ré-indexation.
explorations d’éléments se feront donc de façon à éviter les défauts de cache en mémoire
puisque la lecture entière d’une ligne de cache chargée sera favorisée avant qu’une autre
ne se charge. Pour ce qui est des nœuds locaux-internes et locaux-externes le même phé-
nomène se produit puisque lors d’un recouvrement des communications par les calculs les
nœuds locaux-internes seront tout d’abord explorés, puis par la suite les nœuds locauxexternes.
Le deuxième avantage de cette ré-indexation est le fait d’éviter des conditions
coûteuses dans le code. En effet, une solution naïve pour les bordures physiques serait
d’explorer tous les nœuds du DAG et, pour chacun, de tester si il s’agit d’une racine,
d’une feuille, ou d’un nœud interne. Cela signifierait qu’à chaque itération de temps
et pour chaque élément du domaine (potentiellement très grand), deux tests seraient
effectués. Ces conditions seraient extrêmement coûteuses en performances. Dans cette
solution les racines et les feuilles sont regroupées et les itérateurs (expliqués précédemment)
seront utilisés pour naviguer dans l’une ou l’autre des classes. De même, pour le
recouvrement des communications par les calculs, une solution naïve serait de tester pour
chaque élément si il s’agit d’un nœud local-interne ou local-externe ce qui nuirait aux
performances.
Étant donnée la ré-indexation locale du processeur 1, présentée dans la figure 5.10(b)
et sans prendre en compte les nœuds et arêtes en pointillés, les six tableaux de la structure
de données locale au processeur 1 sont :
cdeg+ = [0, 2, 5, 7, 7, 7, 7, 7, 7], cdeg− = [0, 1, 2, 2, 3, 4, 5, 6, 7]
N
+
E = [2, 3, 4, 5, 6, 0, 1], N −
E = [0, 3, 1, 5, 6, 4, 2]
S = [2, 2, 0, 0, 1, 1, 1], D = [0, 3, 7, 1, 6, 4, 5]
La ré-indexation locale permet donc d’optimiser les performances de la structure de
données et permet également de pouvoir exprimer les huit tableaux locaux à chaque
processeur. Toutefois il est encore nécessaire de paralléliser cette structure de données en
insérant les nœuds et arêtes en pointillés. La première étape de cette parallélisation est
de continuer la ré-indexation commencée avec les éléments locaux à chaque processeur,
sur les éléments en pointillés. Les index des éléments en pointillés doivent être supérieurs
aux index des éléments locaux. Ainsi, étant donnée une arête ei en pointillés, et étant
donnée la ré-indexation des arêtes locales E = {e0, e1, ..., em−1} telle que |E| = m, alors120 Chapitre 5. SkelGIS pour des simulations sur réseaux
la ré-indexation de ei est notée ej où j ≥ m. De même, étant donné un nœud vi en
pointillés, et étant donnée la ré-indexation des nœuds locaux V = {v0, v1, ..., vn−1} tel
que |V | = n, alors la ré-indexation de vi est notée vj où j ≥ n. La fin de cette ré-
indexation est illustrée de la figure 5.10(a) à la figure 5.10(b) et dans la figure 5.11. En
ré-indexant de cette façon les éléments en pointillés, les optimisations mises en place,
pour l’utilisation des lignes de cache et les conditions, sont conservées.
Pour comprendre l’insertion des éléments pointillés dans la structure de données,
continuons à utiliser l’exemple du processeur 1. Dans la figure 5.10(b), il peut être noté
que les nœuds 2 et 3 doivent recevoir chacun une arête entrante du processeur 0. Pour
cette raison, comme illustré dans la figure 5.12, le tableau cdeg− est modifié aux index 3
et 4 en ajoutant 1 à chacun. Comme cdeg− représente les degrés cumulés, l’addition de
ces éléments doit être reportée sur tous les éléments suivants du tableau. Pour continuer
cet exemple, le nœud 2 reçoit en entrée l’arête 7 du processeur 0, et le nœud 3 reçoit
en entrée l’arête 8 du processeur 0. En d’autres termes, les arêtes 7 et 8 font partie du
voisinage entrant des nœuds 2 et 3. Pour cette raison les index 7 et 8 doivent être insérés
dans le tableau N
−
E
comme illustré dans la figure 5.12. En procédant comme suit, la
structure de données distribuée obtenue pour le processeur 1 est :
cdeg+ = [0, 2, 5, 7, 9, 11, 14, 14, 14], cdeg− = [0, 1, 2, 3, 5, 6, 7, 8, 9]
N
+
E = [2, 3, 4, 5, 6, 0, 1, 9, 10, 11, 12, 13, 14, 15], N −
E = [0, 3, 7, 1, 8, 5, 6, 4, 2]
cdeg - = 0 1 2 2 3 4 5 6 7 cdeg- = 0 1 2 3 5 6 7 8 9
+1
+1
N -
E = 0 3 1 5 6 4 2 NE
= 0 3 7 1 8 5 6 4 2
Figure 5.12 – Système de ré-indexation.
En procédant de cette façon pour paralléliser la structure de données, la caracté-
ristique du format CSR qui permet d’accéder au voisinage d’un nœud d’un graphe en
O(1), est conservée pour tous les éléments de la structure, qu’ils proviennent d’autres
processeurs ou pas. De plus, l’optimisation de cache mise en place est conservée.
Il reste un dernier point à aborder afin que la structure de données soit entièrement
parallélisée. En effet, le travail décrit jusqu’à présent permet d’exprimer, pour chaque
processeur, la connectivité de son DAG local y compris avec les éléments qui vont être
communiqués par d’autres processeurs. Afin de pouvoir effectuer les communications
nécessaires, une sorte de cartographie des communications est nécessaire. Grâce à elle,
chaque processeur saura à qui envoyer et de qui recevoir des nœuds et des arêtes. Cette
cartographie va reposer de nouveau sur les degrés cumulés. Le tableau cdegtor, de taille
p + 1, où p est le nombre de processeurs, indique le nombre d’éléments à recevoir de5.3. Structure de données distribuée pour les réseaux 121
chacun des autres processeurs de façon cumulée. À l’inverse, le tableau cdegtos, de taille
p + 1 indique le nombre d’éléments à envoyer à chacun des autres processeurs, de façon
cumulée là encore. Les tableaux complémentaires Ntor
E
, Ntos
E
, Ntor
V
et Ntos
V
indiquent les
index des éléments à recevoir et à envoyer en suivant les tableaux de degrés cumulatifs,
comme cela est le cas pour les tableaux déjà détaillés.
Étant donné un DAG G = (V, E) partitionné en p sous-graphes Gi = (Vi
, Ei), i ∈
[0, p[, tel que V =
S
i
Vi et E =
S
i Ei
, alors la table 5.1 indique la taille des tableaux
locaux à chaque processeur mis en place dans le DDS DDAG présenté.
Tableau Taille
cdeg+ |Vi
|
cdeg− |Vi
|
S |Ei
|
D |Ei
|
N
+
E + N
−
E
P|Vi|
j=0 deg(vj )
cdegtor p
cdegtos p
Ntor
E
cdegtor[p]
Ntor
V
cdegtor[p]
Ntos
E
cdegtos[p]
Ntos
V
cdegtos[p]
Table 5.1 – Taille des tableaux du DDS DDAG.
La taille totale représentée par cette structure de donnée, pour chaque processeur,
est donc
T aille = L + P + Conn + Comm, (5.3)
avec
L = 2 × |Vi
| + 2 × |Ei
|,
P = 2 × p,
Conn =
X
|Vi|
j=0
deg(vj ),
Comm = 2 × cdegtor[p] + 2 × cdegtos[p].
La taille du DDS DDAG pour chaque processeur dépend donc du partitionnement
du réseau pour les opérandes L et Comm, mais également de la connectivité du réseau
pour l’opérande Conn, et enfin du nombre de processeurs utilisés pour l’opérande P de
l’équation (5.3). La structure de données présentée ici peut fonctionner quel que soit
le partitionnement choisi au préalable. Toutefois, nous pouvons très bien noter que le
partitionnement va impacter la taille de la structure de donnée locale à chaque processeur.
Un bon partitionnement est donc aussi important que l’implémentation de la structure de122 Chapitre 5. SkelGIS pour des simulations sur réseaux
données afin d’obtenir de bonnes performances pour la solution de parallélisme implicite.
Le partitionnement implémenté dans SkelGIS, ainsi que deux études de partitionnements
supplémentaires seront détaillés dans la section 5.5.
La suite de cette section permet d’expliquer comment est implémenté chaque composant
de la méthode SIPSim dans SkelGIS pour les réseaux, en fonction de l’implémentation
de la DDS DDAG qui vient d’être détaillée.
5.3.3 Implémentation de SkelGIS pour les réseaux
Nous venons de présenter en détails l’implémentation du DDS DDAG de SkelGIS.
Comme cela a été décrit dans le chapitre 3, le DDS est l’objet principal de la solution et
tout le reste de l’implémentation repose sur lui. Dans cette section, nous allons expliquer
comment chaque composant de la méthode SIPSim utilise l’objet DDAG et sa structure
de données.
Dans la méthode SIPSim, un DPMap permet d’appliquer des données sur un DDS
dans un objet léger. Comme nous l’avons vu, dans un réseau, deux DPMap sont nécessaires,
DPMap_Nodes et DPMap_Edges. L’objet DPMap peut alors être comparé au
tableau values du format CSR. Les objets DPMap_Nodes et DPMap_Edges stockent
leur données dans un simple tableau à une dimension, ce qui permet d’obtenir des objets
très légers. Ce tableau à une dimension, dont les indices vont suivre la ré-indexation
de l’objet DDAG, vont donc représenter les tableaux à une dimension de la figure 5.11.
Pour reprendre l’exemple de la figure 5.10, pour le processeur 1, l’élément 4 du tableau
du DPMap_Nodes correspondra donc à une valeur sur le nœud 13 du DAG général. Un
DPMap est donc lié à une instance de DDAG à sa création et se servira de cette instance
de façon systématique dans son implémentation et pour ses interfaces. L’utilisateur va
instancier un certain nombre de DPMap, autant qu’il a besoin de quantités sur le réseau
pour sa simulation.
C’est à partir des instances de DPMap que l’utilisateur va accéder aux interfaces
de programmation afin de coder sa simulation. La première d’entre elles est l’interface
d’itération. Les itérateurs présents dans SkelGIS pour les réseaux ont déjà été présentés.
L’ensemble de ces itérateurs va pouvoir être initialisé par le biais de deux méthodes des
objets DPMap. L’une permettra de se placer au commencement de la classe des éléments
et l’autre permettra de se placer à la fin de la classe des éléments. Chaque objet d’itération
est ensuite un objet indépendant qui va se charger d’incrémenter le positionnement grâce
à l’opérateur ++ et de le comparer grâce aux opérateurs ≤, ≥, et ==. L’opérateur ++
ne peut, bien entendu, être efficace que grâce à l’indexation contiguë des éléments d’une
même classe qui a été mise en œuvre dans l’objet DDAG.
Enfin, le dernier composant de la méthode SIPSim implémenté dans SkelGIS est lui
aussi lié à l’implémentation du DDAG. Il s’agit de l’applicateur qui a été décrit dans
la section 5.2.3. Un applicateur permet d’appliquer une opération séquentielle, codée
par l’utilisateur grâce aux instances de DPMap et aux interfaces de programmation, sur
un ensemble de quantités à simuler. Un applicateur permet de cacher à l’utilisateur les
échanges MPI qui se produisent avant une opération. L’applicateur pour les réseaux cache
lui aussi les échanges nécessaires à chaque instance de DPMap, en appelant les méthodes5.4. Simulation 1D d’écoulement du sang dans les artères 123
de communication des objets DPMap. Ces méthodes, bien entendu, utilisent directement
les tableaux cdegtor
, cdegtos
, Ntor
E
, Ntos
E
, Ntor
V
et Ntos
V
décrits dans la section 5.3.2.
L’implémentation de la méthode SIPSim mise en place dans SkelGIS, pour le cas des
réseaux, est une solution aboutie et optimisée qui cache à l’utilisateur une très grande
complexité de code. Tous les composants de la méthode SIPSim dépendent de l’implé-
mentation et de l’efficacité de l’objet DDAG et tous ces composants sont liés les uns aux
autres pour fournir une solution adaptée à l’utilisateur, et efficace.
5.4 Simulation 1D d’écoulement du sang dans les artères
Dans cette section est détaillé un cas d’application réel de SkelGIS pour les réseaux.
La simulation présentée étudie l’écoulement du sang dans un réseau artériel. La parallélisation
de cette simulation a été effectuée en collaboration avec Pierre-Yves Lagrée,
Jose-Maria Fullana et Xiaofei Wang [42]. Dans cette section nous allons tout d’abord
expliquer brièvement le modèle mathématique utilisé puis les méthodes numériques appliquées
afin de coder la simulation. Nous détaillerons ensuite la parallélisation de la
simulation en utilisant SkelGIS et présenterons des résultats expérimentaux.
5.4.1 Simulation 1D d’écoulement du sang dans le réseau artériel
Les détails du modèle mathématique et des méthodes numériques utilisées peuvent
être trouvés dans les travaux de Wang et Al [126]. Dans cette section, les informations
utilisées dans cette thèse seront présentées, mais nous ne rentrerons que peu dans les
détails mathématiques de la simulation.
5.4.1.1 Modèle mathématique
Le système d’équations de Navier-Stokes [73] a déjà été décrit dans la section 4.3.1,
il permet d’étudier l’évolution temporelle d’un fluide dans un domaine Ω de l’espace R
3
.
Ces équations peuvent donc également modéliser les écoulements sanguins dans un ré-
seau artériel en trois dimensions. Toutefois, cette simulation est connue pour être très
coûteuse en temps d’exécution et en mémoire utilisée, ce qui la rend généralement uniquement
utilisable de façon locale, sur une unique artère ou sur une confluence de plusieurs
artères, par exemple. Le travail de Wang et Al [126] étudie une simulation sur une unique
dimension pour plusieurs raisons. Tout d’abord, cette simulation étant plus légère elle
peut être exécutée sur un réseau artériel complet. Il est, de plus, possible d’envisager une
simulation en temps réel pour la médecine, en couplant la légèreté de cette simulation
au parallélisme. Enfin, le système 1D capture de façon intéressante le comportement des
ondes sanguines provoquées par les pulsations cardiaques, ce qui donne des informations,
elles aussi intéressantes, sur le système cardio-vasculaire. La simulation présentée traite
le système d’équations de Navier-Stokes suivant une dimension, en intégrant ces équations
sur la section de l’artère, autrement dit en moyennant une solution dans les deux124 Chapitre 5. SkelGIS pour des simulations sur réseaux
dimensions représentant la section du tube artériel. La simulation est alors représentée
par deux EDP qui font le lien entre trois variables : la section de l’artère noté A, le
débit volumétrique Q et la pression artérielle P toutes trois fonctions de x, la dimension
spatiale, et t la dimension temporelle. Ces deux équations sont les suivantes :
( ∂A
∂t +
∂Q
∂x = 0
∂Q
∂t +
∂
∂x (
Q2
A
) + A
ρ
∂P
∂x = −Cf
Q
A
(5.4)
où x représente l’axe longitudinal de l’artère, t représente le temps, et −Cf
Q
A
représente
le coefficient de frottement (avec Cf le coefficient de frottement de la paroi artérielle) et
où ρ représente la densité du sang.
5.4.1.2 Résolution numérique et programmation
La simulation proposée dans les travaux de Wang et Al [126] établie une relation
entre A et P, telle que P = Pext + β(
√
A −
√
A0) + νs
∂A
∂t , où β est le coefficient de raideur
d’une artère, A0 la section de l’artère non déformée, et Pext la pression extérieure des
vaisseaux, et νs le coefficient de viscosité du sang.
Ainsi, si nous posons
U =
A
Q
, Fc =
Q
Q2
A +
β
3ρ
A3/2
!
, Fv =
0
−Cv
∂Q
∂x
, F = Fc + Fv
et
S =
0
−Cf
Q
A +
A
ρ
(
∂(β
√
A0)
∂x −
2
3
√
A
∂β
∂x!
, Cv =
Aνs
ρ
,
alors le système d’équations (5.4) s’écrit sous la forme :
∂U
∂t +
∂F
∂x = S. (5.5)
Dans cette équation il est considéré que Pext est constant suivant l’axe x. Dans l’équation
(5.5), U représente la variable conservative de la loi de conservation présentée à
l’équation (2.9) de la section 2.2.4.2, F le flux et S est un terme supplémentaire appelé
le terme source. Le problème s’apparente donc à la loi de conservation de la masse et de
la quantité de mouvement à une dimension.
Les méthodes des différences finies et des volumes finis ont été utilisées dans les
travaux de Wang et Al pour obtenir différents schémas numériques de la simulation et
comparer les résultats obtenus. La méthode des volumes finis correspond alors à la description
qui en a été faite dans la section 2.2.4.2 puisque la même forme d’équation de
conservation à une dimension est traitée (hormis le terme source). Un schéma de résolution
numérique explicite est donc obtenu pour la simulation au sein de chaque artère
(équation (2.14)). Notons que dans cette simulation la variable U est traitée à l’ordre
2. Afin de résoudre les problèmes d’oscillations provoqués par les méthodes utilisées,5.4. Simulation 1D d’écoulement du sang dans les artères 125
la reconstruction MUSCL (Monotonic Upwind Scheme for Conservative Law) est utilisée
[126] et engendre l’utilisation de variables supplémentaires et de cinq calculs stencils
supplémentaires. Comme dans toute résolution d’EDP, des conditions initiales et des
conditions aux limites du domaine sont spécifiées pour permettre de réduire le spectre
des solutions. Notons que l’EDP, à résoudre ici, simule l’écoulement du sang dans une
unique artère et non sur le réseau. Le calcul des conditions aux limites pour chaque artère
est donc nécessaire au calcul de l’EDP. La particularité des conditions limites de cette
simulation vient du fait qu’il s’agit d’une simulation sur un réseau. Pour cette raison les
conditions limites d’une artère sont liées aux conditions limites des artères auxquelles elle
est connectée. Par conséquent, les conditions limites, dans cette simulation ne sont autre
que les nœuds du réseau. Il s’agit donc d’un cas simple de simulation sur un réseau, où les
nœuds ne représentent pas une simulation à part entière, mais simplement les conditions
aux limites de chaque arête arrivant à une conjonction.
Afin de comprendre le comportement d’un nœud, prenons l’exemple d’un nœud ayant
une artère mère et deux artères filles. Étant donné qu’en chaque artère les quantités A
et Q sont simulées, on a alors en ce nœud l’arrivée de six conditions aux limites des
artères : An+1
p
et Qn+1
p pour les conditions limites de l’artère mère, et A
n+1
d1
, A
n+1
d2
, Q
n+1
d1
et Q
n+1
d2
pour les conditions limites des deux artères filles. Un nœud respecte alors la loi
de conservation des flux suivante
Q
n+1
p − Q
n+1
d1 − Q
n+1
d2 = 0 (5.6)
et la conservation de la quantité de mouvement suivante
1
2
ρ
Qn+1
p
A
n+1
p
!2
+ P
n+1
p −
1
2
ρ
Q
n+1
di
A
n+1
di
!2
− P
n+1
di
= 0, ∀i ∈ {1, 2} (5.7)
L’algorithme 9 reprend l’algorithme 1, de la section 2.2.5, avec les spécificités de la
simulation décrite ici. Il représente donc l’algorithme séquentiel de cette simulation.
5.4.2 Parallélisation avec SkelGIS
La simulation d’écoulement du sang, qui vient d’être décrite, est donc un cas particulier
de simulation sur les réseaux. En effet, dans cette simulation les nœuds servent à
exprimer le lien entre les conditions limites des artères du réseau. C’est l’une des façons
d’utiliser un réseau, parmi d’autres. Toutefois, cette simulation se prête parfaitement à
un premier cas d’application du prototype actuel de SkelGIS. Précisons, tout d’abord
que dans cette simulation les types d’éléments T1 et T2 sont respectivement associés
aux arêtes et aux nœuds du réseau. La simulation est alors organisée en quatre phases,
comme cela a été décrit dans la section 5.1 :
1. communication des arêtes calculées à t − 1,
2. calcul des nœuds à t,
3. communication des nœuds calculés à t,126 Chapitre 5. SkelGIS pour des simulations sur réseaux
Algorithme 9 : Algorithme séquentiel de la simulation artérielle.
Création ou lecture du réseau
Création ou lecture du domaine discrétisé pour chaque artère
Création des variables appliquées sur le réseau (artères et nœuds)
Initialisation des variables
Définition du pas de temps : t
Définition du temps maximal : tmax
while t < tmax do
calcul des conditions limites du réseau (racines et feuilles) pour t
for chaque nœud du réseau do
calcul des schémas (5.4) et (5.5)
end
for chaque artère du réseau do
for chaque élément du maillage x do
calcul du schéma numérique (5.3) pour t et x
end
end
end
4. calcul des arêtes à t.
La description de la simulation nous offre ensuite toutes les clés permettant de coder cette
simulation en utilisant SkelGIS. Tout d’abord cette simulation n’instancie qu’un unique
réseau qui représente le réseau artériel à étudier. Une unique instanciation de l’objet
DDAG est donc nécessaire. Il est ensuite possible d’identifier les variables principales de
la simulation, à savoir A et Q, qui représentent des données appliquées sur les arêtes
du réseau. Elles nécessitent donc deux instanciations de l’objet DPMap_Edges. Au sein
d’une arête, une discrétisation suivant une dimension est effectuée et un schéma numé-
rique d’ordre 2 est appliqué à chaque itération de temps. Pour cette raison, le paramètre
de template T est pour les deux variables double∗
, et le paramètre node_access est égal
à 2 comme illustré dans la figure 5.13. Afin d’effectuer les calculs de condition aux limites
DPMap_Edges A
DPMap_Edges Q
Figure 5.13 – Déclaration des variables A et Q
sur les nœuds du réseau, l’instanciation d’une donnée plaquée sur les nœuds est ensuite
nécessaire, même si les nœuds ne lisent et n’écrivent que des données sur les arêtes. La
figure 5.14 illustre cette instanciation qui est très simple. Aucune discrétisation n’est
effectuée au sein des nœuds du réseau (équations (5.6) et (5.7)). D’autres variables, qui
n’ont pas été détaillées sont nécessaires au codage de la simulation, notamment pour la
méthode de reconstruction MUSCL évoquée précédemment. Le principe reste cependant
le même pour toutes les variables de la simulation appliquées aux arêtes ou aux nœuds.5.4. Simulation 1D d’écoulement du sang dans les artères 127
DPMap_Nodes nd
Figure 5.14 – Déclaration d’une variable nd sur les nœuds du réseau
Enfin, outre les instanciations de variables, les paramètres qui caractérisent les arêtes
et les nœuds du réseau doivent être instanciés. Dans ce cas, ces données sont utilisées
localement et pour cette raison le node_access du DPMap correspondant est positionné
à 0. Une fois le réseau, les variables, et les paramètres de la simulation instanciés, les
itérations de temps peuvent commencer ainsi que la résolution des schémas numériques.
L’algorithme 10 illustre la fonction main codée par l’utilisateur. Cette fonction reste très
proche de la version séquentielle, à l’exception près qu’elle définit les objets SkelGIS qui
viennent d’être évoqués.
Algorithme 10 : Fonction main codée par l’utilisateur
Création ou lecture du réseau DDAG
Création des variables appliquées sur le réseau (artères et nœuds) :
DPM ap_Edges < double∗, 2 > A
DPM ap_Edges < double∗, 2 > Q
DPM ap_Nodes < double, 0 > nd
...
Initialisation des variables de paramètres
Définition du pas de temps : t
Définition du temps maximal : tmax
while t < tmax do
apply_listi({A,Q,etc.},{nd,etc.},bloodflow1,bloodflow2)
end
On peut observer que la différence entre cette fonction principale et sa version sé-
quentielle est d’appeler, dans la boucle d’itérations en temps, l’applicateur apply_listi.
Comme cela a déjà été expliqué dans la section 5.2, l’applicateur apply_listi permet d’appliquer
des schémas numériques implicites en quatre étapes. Cette simulation convient
donc à l’utilisation de cet applicateur. Cet appel est très important puisqu’il est en charge
de cacher à l’utilisateur les échanges nécessaires aux calculs dans les phases 1 et 3 de la
simulation. Les opérations bloodflow1 et bloodflow2, données en paramètres de l’applicateur,
sont les fonctions séquentielles utilisateur qui contiennent le code de calcul des
phases 2 et 4 décrites précédemment.
Les algorithmes 11 et 12 illustrent les opérations bloodflow1 et bloodflow2. Les
structures de ces opérations sont, là encore, très proches de la version séquentielle. La
seule contrainte imposée à l’utilisateur, tout comme dans la fonction main, est d’utiliser
les notions propres à SkelGIS pour manipuler les instances des objets DPMap_Edges
et DPMap_Nodes : les itérateurs ; les accesseurs ; et les fonctions de voisinages. La
première opération, décrite dans l’algorithme 11 représente donc le code de la phase
2 de la simulation. Ce code consiste, tout d’abord à calculer les conditions limites du128 Chapitre 5. SkelGIS pour des simulations sur réseaux
réseau, à savoir les racines et les feuilles. Pour cela, les itérateurs spécifiques à ces deux
classes d’éléments sont utilisés ainsi que l’opérateur [] et les fonctions de voisinage d’un
nœud. Par la suite, les conditions aux limites des artères sont calculées. Ces calculs se
produisent aux points de conjonction (nœuds), et sont effectués en fonction des résultats
obtenus à l’itération précédente sur les artères qui se rencontrent en ce nœud. Les autres
nœuds du réseau sont donc ensuite calculés dans l’opération en appliquant les schémas
numériques (5.6) et (5.7). La deuxième opération, décrite dans l’algorithme 12 repré-
Algorithme 11 : Opération bloodflow1 décrivant les calculs effectués à chaque
itération de temps sur les nœuds.
Data : {DPMap}
Result : Modification de {DPMap}
ItR := itérateur de début sur les racines
endItR := itérateur de fin sur les racines
while ItR≤endItR do
Application des conditions limites pour les racines avec : A[ItR], Q[ItR],
nd[ItR], A.getInEdges(ItR), Q.getOutEdges(ItR) ...
ItR++
end
ItL := itérateur de début sur les feuilles
endItL := itérateur de fin sur les feuilles
while ItL≤endItL do
Application des conditions limites pour les feuilles avec : A[ItL], Q[ItL],
nd[ItL], A.getInEdges(ItL), Q.getOutEdges(ItL) ...
ItL++
end
ItC= itérateur de début sur les nœuds
endItC := itérateur de fin sur les nœuds
while ItC≤endItC do
Application des schémas numériques des nœuds (5.4) et (5.5) avec : A[ItC],
Q[ItC], nd[ItC], A.getInEdges(ItC), Q.getOutEdges(ItC) ...
ItC++
end
sente, quant à elle, le code de la phase 4 de la simulation. Ce code consiste à appliquer
le schéma numérique issu de la méthode des volumes finis appliquée à l’équation (5.5).
Pour cela, les itérateurs spécifiques à ces deux classes d’éléments sont utilisés ainsi que
l’opérateur [] et les fonctions de voisinage d’une arête.
L’utilisation de SkelGIS pour coder cette simulation d’écoulement du sang dans les
artères répond donc aux attentes annoncées, et propose une solution très proche du sé-
quentiel, aussi bien en terme de structure de programme qu’en terme de programmation.
En effet, aucune difficulté technique n’est introduite par la solution et aucun code pa-5.4. Simulation 1D d’écoulement du sang dans les artères 129
Algorithme 12 : Opération bloodflow2 décrivant les calculs effectués à chaque
itération de temps sur les arêtes.
Data : {DPMap}
Result : Modification de {DPMap}
ItA= itérateur de début sur les artères
endItA := itérateur de fin sur les artères
while ItA≤endItA do
for chaque élément du maillage de l’artère x do
calcul du schéma numérique issu de l’équation (5.4) pour t et x avec :
A[ItA], Q[ItA], nd[ItA], A.getSrcNode(ItA), Q.getDstNode(ItA) ...
end
ItA++
end
rallèle n’a été produit par l’utilisateur. Dans la prochaine section vont être étudiés en
détails les résultats expérimentaux obtenus sur cette version SkelGIS de la simulation.
5.4.3 Résultats
Dans cette section, nous allons présenter les résultats que nous avons obtenus sur
la simulation d’écoulement du sang dans le réseau artériel décrite précédemment, et codée
avec la bibliothèque SkelGIS. Nous appellerons cette simulation bloodflow-SkelGIS.
Les résultats expérimentaux se découpent en trois parties. Tout d’abord, l’efficacité du
recouvrement des communications par les calculs a été évaluée. Cette optimisation du
code parallèle est, en effet, possible grâce à l’indexation mise en place dans la structure
de données. Nous avons décrit ce point dans les sections 5.2 et 5.3.2. Les expériences
suivantes utilisent toutes le recouvrement des communications par les calculs. L’implé-
mentation bloodflow-SkelGIS sera ensuite comparée à une version OpenMP de la même
simulation, que nous noterons bloodflow-OpenMP. Trois comparaisons seront effectuées,
les temps d’exécution, les accélérations des programmes ainsi que les métriques de Halstead
[65]. Enfin, une dernière phase d’expérimentation donnera les temps d’exécution
et les accélérations de bloodflow-SkelGIS sur deux grappes du top500 international de
novembre 2013, avec des tailles variables de réseaux. Les machines utilisées dans l’ensemble
de ces expériences sont détaillées dans la table 5.2. Il y a tout d’abord la grappe
“Babbage”, de l’Institut Jean le Rond d’Alembert UMR CNRS Université de Paris 6, qui
est une grappe de taille moyenne mais bien équipée, les nœuds thin du super-calculateur
TGCC-Curie classé vingtième dans le top500 de novembre 2013, et enfin les nœuds IBM
Blue Gene/Q de super-calculateur Juqueen en Allemagne classé huitième sur la même
liste. Notons que Juqueen nous permet également de valider SkelGIS sur une architecture
très différente des autres clusters. Ces machines nous ont permis de procéder à des tests
de performances allant de 8 à 8192 cœurs.
Afin de détailler au mieux les expériences effectuées, notons que la simulation
bloodflow-SkelGIS est exclusivement composée d’opérations sur des variables à double-130 Chapitre 5. SkelGIS pour des simulations sur réseaux
Calculateur Babbage TGCC Curie Juqueen
Processeur 2×Intel Xeon 2×SandyBridge IBM PowerPC
(3 GHz) (2.7 GHz) (1.6 GHz)
Cœurs/nœud 12 16 16
Mémoire/nœud 24 GB 64 GB 16 GB
Compilateur [-O3] OpenMPI Bullxmpi MPICH2
Réseau Infiniband Infiniband 5D Torus 40 GBps
Table 5.2 – Spécifications matérielles des machines utilisées
précision et que chaque expérience présentée dans cette partie a été lancée quatre fois
chacune, puis moyennée. Enfin, l’écart type noté sur l’ensemble des exécutions de la
simulation est très faible, et inférieur à 2%.
5.4.3.1 Recouvrement des communications par les calculs
La première expérience effectuée est celle qui permet de valider l’optimisation de
recouvrement des communications par les calculs. Cette expérience a été menée sur le
cluster “Babbage” de l’Université de Paris 6, sur un réseau de type arbre contenant 15.000
arêtes et 15.000 nœuds et un degré maximal très faible de 3. La table 5.3 donne l’ensemble
des temps d’exécution obtenus de 16 à 384 processeurs utilisés, et la figure 5.15 illustre,
de son côté, l’accélération de la simulation bloodflow-SkelGIS avec et sans l’optimisation
de recouvrement.
Cœurs Sans recouvrement Avec recouvrement Gain de temps %
16 12715.9 12386.2 2.59
32 6462.38 6189.79 4.22
64 3491.87 3124.74 10.5
128 1912.89 1581.46 17.3
256 1225.55 843.103 31.2
384 1166.18 612.787 47.45
Table 5.3 – Temps d’exécution en secondes de bloodflow-SkelGIS avec et sans l’optimisation de
recouvrement des communications par les calculs.
Tout d’abord, l’optimisation mise en place est efficace, comme cela pouvait être attendu.
On note, en effet, une différence très nette, à la fois pour les temps d’exécution et
sur les accélérations, entre la simulation sans le recouvrement et la simulation avec le recouvrement.
Cependant, il paraît presque surprenant d’obtenir des gains de performance
aussi importants. La version sans recouvrement fait émerger une faiblesse du prototype
SkelGIS. En effet, même si de meilleures performances étaient attendues avec cette optimisation
de recouvrement, on observe que le speedup de la version sans recouvrement
devient moins bon à partir de 128 processeurs. Cette faiblesse n’a pas été étudiée avec
précision. Nous pensons que le problème peut venir du partitionnement de graphes mis5.4. Simulation 1D d’écoulement du sang dans les artères 131
Coeurs
Accélération
sans recouvrement
avec recouvrement
idéal
16 64 128 256 384
16 64 128 256 384
Figure 5.15 – Accélération de la simulation bloodflow-SkelGIS sans et avec le recouvrement des
communications par les calculs.
en place dans le prototype actuel de SkelGIS, qui sur un grand nombre de processeurs,
fournit une distribution moyennement équilibrée. Les résultats de ce partitionnement
seront présentés en détails dans la section 5.5.1. Ce résultat montre, quoi qu’il en soit,
l’importance de l’optimisation mise en place dans la structure de données DDAG.
5.4.3.2 Comparaison avec OpenMP
La méthode SIPSim et son prototype implémenté SkelGIS, se proclame comme une
solution de parallélisme implicite très simple d’utilisation, car adaptée aux simulations
scientifiques, et proposant de très bonnes performances parallèles. De son côté, le langage
OpenMP est reconnu et utilisé dans les domaines scientifiques de par sa simplicité
d’utilisation, tout en sachant que les performances obtenues ne sont pas nécessairement
excellentes. Une comparaison des deux approches paraît donc être une bonne expérience,
aussi bien en terme de performances qu’en terme de difficulté de programmation.
Une version OpenMP de la simulation d’écoulement du sang dans le réseau artériel
a été développée par les mathématiciens à l’origine de cette simulation. La parallélisation
OpenMP dite à grain fin (fine-grain), est la parallélisation la plus utilisée par les
non-informaticiens et consiste à déclarer des boucles parallèles dans le code par la simple
directive #pragma omp parallel for. Ces boucles sont alors automatiquement réparties
entre les différents processus (threads) créés, ce qui rend la solution totalement implicite.
La simulation OpenMP qui a été implémentée ici n’est toutefois pas une implémentation
fine-grain. Il s’agit d’une parallélisation à gros grain (coarse-grain), où la directive plus
générale #pragma omp parallel est utilisée. Ce type de parallélisation OpenMP n’est
pas totalement implicite, et donc plus complexe qu’une parallélisation fine-grain. Une
parallélisation coarse-grain ressemble, dans son principe, à une parallélisation MPI. En
effet, une zone parallèle générale est déclarée comme entre les fonctions MPI_Init et
MPI_Finalize. Dans cette zone parallèle, plusieurs threads sont créés et vont, sauf précision
inverse de l’utilisateur, exécuter la même zone de code. Aucun transfert de données
n’est nécessaire entre les processus qui partagent la même mémoire, toutefois des syn-132 Chapitre 5. SkelGIS pour des simulations sur réseaux
chronisations sont implicitement effectuées entre les processus pour garantir la cohérence
des données et pour contrôler leur accès. Ce type de parallélisation permet de mettre
en place des programmes parallèles en suivant le paradigme SPMD et le parallélisme
de données, tout comme le modèle PGAS le propose. Ce type de parallélisation obtient
dans les cas complexes de meilleures performances que la parallélisation fine-grain, mais
n’est pas totalement implicite aux yeux de l’utilisateur [109]. En effet, étant donné que
tous les threads exécutent le même code, il est nécessaire de gérer une distribution des
données entre les threads. Cette distribution est à la charge de l’utilisateur ce qui rend
la solution plus complexe à mettre en œuvre. De plus, une réflexion est nécessaire pour
déclarer les variables partagées et locales. Cette parallélisation ayant été effectuée par
des mathématiciens, elle donne un aperçu des performances qu’il est possible d’obtenir
en utilisant OpenMP sans connaissances poussées en informatique.
La table 5.4 indique les temps d’exécution obtenus sur un unique nœud thin du
super-calculateur TGCC-Curie. Comme indiqué dans la table 5.2, un nœud contient 16
cœurs. La figure 5.16 trace les temps d’exécution obtenus en fonction du nombre de
cœurs utilisés, avec une échelle logarithmique, ce qui permet de mieux apprécier cette
comparaison.
Cœurs OpenMP SkelGIS Gain de temps %
1 2209.77 2006.92 9.2
2 2068.33 959.095 53.6
4 1122.73 497.424 55.7
8 621.72 273.011 56
16 341.95 156.59 54.2
Table 5.4 – Temps d’exécution en secondes de bloodflow-OpenMP et bloodflow-SkelGIS.
Coeurs (log2)
Temps d'exécution (log2)
OpenMP
SkelGIS
1 2 4 8 16
250 500 1000 2000
Figure 5.16 – Comparaison des temps d’exécution entre bloodflow-OpenMP et
bloodflow-SkelGIS avec une échelle logarithmique.
La table 5.4 et la figure 5.16 montrent que la version OpenMP obtient un très mauvais
temps d’exécution avec deux cœurs, et que ce problème se répercute, par conséquent, sur5.4. Simulation 1D d’écoulement du sang dans les artères 133
le reste des temps d’exécution. La figure 5.17 montre l’accélération des simulations. On
peut alors voir que le speedup obtenu par la version OpenMP n’est pas mauvais, car
proche du linéaire, outre le pallier entre 1 et 2 cœurs. La version SkelGIS, de son côté,
obtient de très bonnes performances. Le temps d’exécution séquentiel est tout d’abord
meilleur que le temps séquentiel de la version OpenMP de 9%. De plus, l’accélération du
programme étant quasi linéaire, et proche de l’idéal (figure 5.17), cette performance est
conservée en augmentant le nombre de processeurs. Le résultat de la version OpenMP
nuit à une bonne comparaison des deux approches. Cependant, notons que la pente
de l’accélération de la version OpenMP est moins importante que celle de la version
SkelGIS. Pour cette raison, si la version OpenMP était retravaillée afin de résoudre le
pallier à 2 cœurs, les temps d’exécution et les accélérations obtenus seraient toujours
moins performants pour la version OpenMP.
Coeurs
Accélération
OpenMP
SkelGIS
idéal
1 2 4 8 16
1
2
4
8 16
Figure 5.17 – Comparaison des accélérations entre bloodflow-OpenMP et bloodflow-SkelGIS.
Mais le point de la comparaison n’est pas réellement ici. En effet, qu’une version
OpenMP meilleure que la version SkelGIS n’existe ou pas, la version SkelGIS obtient
des performances très intéressantes et qui pourraient elles aussi être améliorées. En plus
des performances, il paraît intéressant de comparer la difficulté de programmation des
deux programmes. Pour expérimenter ce dernier point, les métriques de Halstead ont une
nouvelle fois été utilisées pour obtenir une évaluation du volume du programme écrit, de
la difficulté de programmation, et de l’effort à fournir. La table 5.5 donne les métriques
calculées pour chacune des deux simulations : bloodflow-OpenMP et bloodflow-SkelGIS.
L’effort à fournir pour écrire la version SkelGIS est environ 45% moins grand que
l’effort à fournir pour écrire la version OpenMP coarse-grain. Pourtant un programme
OpenMP ne consiste qu’en un ensemble de directives de compilation à appliquer à son
code, alors pourquoi un tel écart ? Comme le montre le tableau des métriques, cet écart
vient principalement d’un facteur, celui du volume du programme écrit qui est 30% plus
important dans la version OpenMP. Il s’agit d’un point très important pour la méthode
SIPSim et pour SkelGIS. En effet, SkelGIS gère, par l’intermédiaire de l’objet DDAG,
l’ensemble de la structure de données de façon transparente pour l’utilisateur. Ce point
simplifie énormément le code utilisateur puisque celui-ci n’a plus à se soucier de coder
une structure de données, qui peut être complexe suivant les cas. Pour les DMatrix, cet134 Chapitre 5. SkelGIS pour des simulations sur réseaux
Métriques OpenMP SkelGIS Gain %
N1 2607 2692 -3.2
N2 10737 7424 30.8
n1 365 231 36.7
n2 501 282 43.7
V 130213.73 91072.48 30
D 3911.18 3040.68 22.2
E 509.3M 276.9M 45.6
Table 5.5 – Métriques de Halstead pour les versions bloodflow-OpenMP et bloodflow-SkelGIS.
avantage était moins évident puisqu’une matrice est une structure de données très simple
à coder. Dans le cas des réseaux, l’intérêt du composant DDS, de la méthode SIPSim,
prend tout son sens et simplifie de façon significative le code de la simulation. La mé-
thode SIPSim étant une solution spécifique de parallélisme implicite en comparaison de
OpenMP, ce résultat devait être observé. Cependant, notons qu’un autre phénomène apporte
du poids au volume de code à fournir pour la version OpenMP. En effet, le fait
que la version OpenMP étudiée produise une parallélisation de type coarse-grain ajoute
du travail à l’utilisateur. Comme nous l’avons déjà décrit, cette version de parallélisation
OpenMP est proche d’une parallélisation MPI et l’utilisateur doit procéder à la décomposition
du domaine par lui-même. Cela nuit au parallélisme implicite, mais cela nuit
également au volume de code à fournir.
La simulation bloodflow-SkelGIS est donc plus intéressante en tout point par rapport
à la version bloodflow-OpenMP. La version SkelGIS obtient, en effet, de très bonnes
performances et de très bonnes métriques de Halstead.
5.4.3.3 Performances de SkelGIS
La méthode SIPSim et son implémentation SkelGIS sont conçues pour être exécutées
sur des architectures à mémoire distribuée. Bien qu’un programme parallèle MPI
fonctionne très convenablement sur une architecture à mémoire partagée, comme nous
venons de le voir, ce n’est pas son objectif premier. Cette nouvelle série d’expériences
permet donc d’estimer les performances de la bibliothèque SkelGIS sur des données de
taille importante et variable et sur des machines de configuration et de taille variables.
La table 5.6 référence les expériences qui ont été menées.
Nombre de nœuds et d’arêtes calculateur utilisé
Expérience 1 15k TGCC-Curie
Expérience 2 50k Juqueen
Expérience 3 100k Juqueen
Expérience 4 500k Juqueen
Table 5.6 – Expériences de performance sur bloodflow-SkelGIS.5.4. Simulation 1D d’écoulement du sang dans les artères 135
Les temps d’exécution obtenus pour l’ensemble de ces expériences sont réunis dans
la table 5.7. Le nombre de cœurs utilisés pour chaque expérience n’est pas le même
pour plusieurs raisons. Tout d’abord, la longueur des traitements et le nombre d’heures
qui nous étaient allouées sur chaque calculateur ne nous permettaient pas d’effectuer
des expériences trop longues. Pour cette raison, les expériences sur les réseaux de taille
50k et 100k ne commencent pas à 8 cœurs, et l’expérience sur un réseau de taille 500k
commence elle à 1024 cœurs. De plus, les clusters utilisés sont très demandés et le nombre
d’utilisateurs est très important. Des files d’attente pour les expériences sont donc mises
en place sur ces machines. Plus il y a de cœurs réservés pour une expérience, plus le délai
d’attente pour que l’expérience soit lancée est long. Pour cette raison, nous n’avons pas
réservé plus de 1024 cœurs sur le TGCC-Curie, et nous ne sommes pas montés au delà
de 8192 cœurs pour Juqueen. Enfin, sur Juqueen notamment, des contraintes très fortes
sur l’utilisation des machines sont mises en place. Le temps de calcul alloué à chaque
cœur possède un minimum et un maximum à respecter, aussi nous n’avons pu descendre
sous 256 cœurs pour les traitements sur les réseaux de taille 50k et 100k. Afin de mieux
appréhender les résultats obtenus, les accélérations de la simulation bloodflow-SkelGIS
pour chacune des expériences sont représentées dans les figures 5.18, 5.19 et 5.20.
Cœurs 15k TGCC 50k Juqueen 100k Juqueen 500k Juqueen
8 10080.1
16 5288.56
32 2680.21
64 1372.12
128 743.103
256 416.919 12602.20 24170.50
512 247.537 6976.98 12650.60
1024 178.8 3869.46 7043.42 30615.20
2048 2624.44 4122.97 16239.50
4096 1254.94 2657.76 8959.82
8192 5606.09
Table 5.7 – Temps d’exécution en secondes de bloodflow-SkelGIS.
Concernant la première expérience, le même jeu de données que dans l’expérience
sur le recouvrement a été utilisé. Hors, nous pouvons remarquer dans la figure 5.15 que
l’accélération est meilleure que dans la figure 5.18. En d’autres termes, l’accélération de
l’expérience semble meilleure sur la grappe “Babbage” que sur le TGCC-Curie. Cette
différence de performances peut être due au fait que le code SkelGIS réagit mieux à la
configuration matérielle du cluster “Babbage”. Toutefois cette explication parait étonnante.
Toutefois, notons que les performances sont tout de même très bonnes sur un
réseau de taille très modeste.
Dans les expériences suivantes, la taille des réseaux est augmentée et l’architecture
utilisée reste identique ce qui permet d’analyser le passage à l’échelle de SkelGIS. Il peut
être observé dans les figures 5.19 et 5.20 que les accélérations obtenues sont très convain-136 Chapitre 5. SkelGIS pour des simulations sur réseaux
Coeurs
Accélération
15k
idéal
8 64 256 512 1024
8 256 512 1024
Figure 5.18 – Accélération de bloodflow-SkelGIS sur un DAG de 15k arêtes et nœuds sur le
TGCC.
Coeurs
Accélération
50k
100k
idéal
256 1024 2048 4098
256 1024 2048 4098
Figure 5.19 – Accélération de bloodflow-SkelGIS sur des DAGs de 50k et 100k arêtes et nœuds
sur Juqueen.
Coeurs
Accélération
500k
idéal
1024 2048 4098 8192
2048 4098 8192
Figure 5.20 – Accélération de bloodflow-SkelGIS sur un DAG de 500k arêtes et nœuds sur
Juqueen.5.5. Partitionnement de réseaux 137
cantes. Le passage à l’échelle y est clairement bon puisque proche d’un résultat linéaire
jusqu’à 4098 processeurs. Sur la taille de réseau la plus importante, les performances sont
très bonnes jusqu’à 8192 processeurs.
Les résultats analysés dans cette section montrent que SkelGIS est convainquant en
plusieurs aspects. Tout d’abord l’optimisation de recouvrement rend la solution très performante,
et le passage à l’échelle est vérifié jusqu’à un très grand nombre de processeurs.
De plus, en comparaison avec une version coarse-grain OpenMP, SkelGIS montre des
performances très bonnes sur une architecture à mémoire partagée. OpenMP étant une
référence dans le parallélisme implicite, les résultats des métriques de Halstead montrent
que SkelGIS est très simple d’utilisation et qu’il peut même être plus simple qu’une
version séquentielle, du fait de la structure de données implicite. Pour finir, le passage
à l’échelle d’une simulation implémentée avec SkelGIS est bon et semble robuste sur
différentes configurations matérielles.
5.5 Partitionnement de réseaux
Dans cette section nous allons expliquer le choix d’implémentation qui a été mis en
place pour partitionner un réseau dans SkelGIS. Cette solution a montré des résultats
intéressants, comme nous l’avons vu dans la section 5.4.3. Les détails de cette méthode
de partitionnement seront décrits dans cette section, et nous verrons qu’elle contient un
certain nombre de désavantages. Un travail récent, en collaboration avec Rob Bisseling,
a permis d’étudier avec plus de précision le problème de partitionnement qui est posé
par les réseaux, mais aussi la façon dont on peut résoudre ce problème en utilisant le
partitionneur Mondriaan [120]. Les deux méthodes de partitionnement récemment mises
en place sont également introduites et évaluées dans cette section.
5.5.1 Partitionnement par regroupement d’arêtes sœurs
Il faut tout d’abord remarquer que dans le type de simulations qui nous intéresse des
calculs sont effectués à la fois sur les nœuds et sur les arêtes du graphe qui représente le
réseau. Nous avons donc deux solutions pour partitionner le graphe. On peut tout d’abord
choisir de distribuer les deux types d’éléments sur les processeurs, ce qui représente le
véritable problème de partitionnement d’un réseau. Ce problème est toutefois complexe
à résoudre, et il conduit également à une gestion des communications plus complexe.
L’autre solution est alors de ne distribuer que les nœuds ou que les arêtes et de dupliquer
les nœuds ou les arêtes manquantes aux coupures, afin de permettre les calculs. Dans
ce cas, les communications sont moins difficiles à gérer dans la solution de parallélisme
implicite. L’implémentation choisie dans le prototype actuel de SkelGIS pour les réseaux
a été de partitionner les arêtes sur les processeurs, où le calcul était considéré comme
plus lourd, et de dupliquer les nœuds sur les différents processeurs, aux coupures des
différentes parties, où le calcul était considéré comme plus léger. Nous avons donc fait
le choix, dans un premier temps, de simplifier le problème en ne partitionnant que les138 Chapitre 5. SkelGIS pour des simulations sur réseaux
arêtes du réseau et en dupliquant les nœuds. De plus, nous avons également limité, et
donc modifié, le problème en considérant une sous-partie des réseaux qui peuvent être
représentés par des DAG.
Comme cela a déjà été décrit dans l’état de l’art 2.3, un partitionnement de graphe
découpe l’ensemble des nœuds d’un graphe G = (V, E) en p parties distinctes V0, . . . , Vp−1
telles que pour i 6= j, Vi∩Vj = ∅ et telles que V =
S
i
Vi
. Le partitionnement doit répondre
à la contrainte d’équilibrage de charge suivante
ω(Vi) ≤ (1 + )
ω(V )
p
(5.8)
où est le pourcentage de non-équilibrage accepté. peut être choisi soit automatiquement
par la solution, soit par l’utilisateur. Enfin, le partitionnement doit minimiser le
nombre d’arêtes coupées, aussi appelé edge-cut.
Dans le prototype de SkelGIS, nous avons choisi de partitionner les arêtes du graphe.
Le problème de partitionnement doit donc être transposé aux arêtes. Ce problème n’est
pas nouveau et les travaux de Kim et Al [81] se sont, par exemple, intéressés à partitionner
les arêtes en coupant les nœuds et en les dupliquant sur chaque processeur. Nous avons
toutefois développé notre propre méthode de partitionnement afin d’essayer de tirer parti
des DAG et du cas particulier des simulations numériques sur les réseaux. Notre méthode
de partitionnement transforme tout d’abord le DAG en un graphe G0 plus grossier que
nous appelons méta-graphe. En reprenant les définitions introduites dans la section 5.3,
nous allons décrire la construction du graphe G0
. Les arêtes sortantes et entrantes d’un
nœud v ∈ V sont respectivement notées N
+
E
(v) et N
−
E
(v). Deux arêtes ei et ej sont
considérées comme étant sœurs si S(ei) = S(ej ) et D(ei) 6= D(ej ). L’ensemble des
arêtes sœurs d’un nœud v ∈ V est défini comme égal à N
+
E
(v). Le méta-graphe G0 du
réseau G est alors défini comme le graphe G0 = (V
0
, E0
) où V
0 = {N
+
E
(v), ∀v ∈ V } et
E0 = {(N
+
E
(v1), N +
E
(v2)) ∈ V
0 × V
0
, ∀v1 ∈ V, v2 ∈ N
+
V
(v1)}. La figure 5.21 illustre le
réseau de type DAG G et le méta-graphe G0 associé.
Figure 5.21 – Exemple de réseau G (à gauche) de type DAG, et du méta-graphe G0 associé (à
droite).5.5. Partitionnement de réseaux 139
Nombre de nœuds Degré maximum
Arbre 1 10k 10
Arbre 2 10k 2
Arbre 3 16k 5
Table 5.8 – Type d’arbres utilisés pour évaluer le partitionnement.
Dans le méta-graphe G0
les nœuds sont les blocs d’arêtes sœurs du graphe G, et les
arêtes représentent les liens entre ces blocs par l’intermédiaire des nœuds de G. Notons
que les nœuds de G0
sont assortis d’un poids, qui est égal au nombre d’arêtes sœurs de
G représentées par le nœud de G0
. En d’autres termes, pour un nœud v ∈ V et un nœud
v
0 = N
+
E
(v) ∈ V
0
, on associe un poids ω(v
0
) = |N
+
E
(v)|. En partitionnant ce méta-graphe
G0 deux problèmes sont résolus à la fois :
— Le problème de partitionnement posé redevient un problème standard de partitionnement
des nœuds d’un graphe.
— Le partitionnement des nœuds de G0
, en tenant compte de leur poids, revient
à un partitionnement de blocs d’arêtes sœurs dans G, ce qui favorise la localité
géographique du résultat.
En revenant ainsi à un problème de partitionnement standard de type edge-cut, il aurait
été possible d’utiliser un partitionneur de graphe comme METIS [77] ou Scotch [100].
Toutefois, nous ne connaissions pas ces solutions au moment du développement de ce
prototype de SkelGIS. Cette solution pourra être envisagée dans les prespectives de ce
travail pour comparer avec plus de détail les différentes approches de partitionnementpossible.
Nous pouvons également noter qu’en envisageant ce prototype de SkelGIS nous
avons préféré une solution simple et sans dépendance pour partitionner uniquement les
DAG, et non tous les graphes comme le proposent METIS et Scotch.
Afin d’évaluer les limites du partitionnement de SkelGIS, nous avons effectué quelques
expériences en calculant le nombre d’arêtes dont chaque processeur a la charge, et les
communications effectuées par chaque processeur, en terme de nombre d’octets et de
temps effectif. Nous avons mené ces expériences sur trois types d’arbres différents, générés
aléatoirement, dont la description est résumée dans la table 5.8. Le premier arbre ainsi
obtenu est un arbre plutôt large et peu profond alors que le second arbre est à l’inverse
un arbre profond et peu large. Enfin, le dernier arbre est un arbre qui peut être considéré
comme assez équilibré en profondeur et en largeur.
La table 5.22 donne le nombre moyen d’arêtes dont chaque processeur est en charge
à chaque itération de simulation, et l’écart type maximal obtenus par rapport à cette valeur
moyenne. Ainsi, plus l’écart type est proche de la valeur moyenne, plus l’équilibrage
de charge peut être considéré comme mauvais. On peut observer, dans cette table, que
l’équilibrage de charge en terme de nombre d’arêtes est bon puisque l’écart type obtenu
est très faible comparé à la valeur moyenne. On peut également observer que le partitionnement
effectué est meilleur pour les arbres 2 et 3 que pour un arbre large comme
l’arbre 1.
La table 5.23 représente le nombre moyen d’octets que chaque processeur doit échan-140 Chapitre 5. SkelGIS pour des simulations sur réseaux
Arbre 1 Arbre 2 Arbre 3
Nb de processeurs moy ect moy ect moy ect
4 2499.75 2.68 2499.75 0.43 4036.25 0.43
8 1249.87 1.45 1249.87 0.78 2018.12 0.6
16 624.94 1.25 624.94 0.66 1009.06 0.24
32 312.47 1.6 312.47 1.03 504.53 0.83
64 156.23 1.66 156.23 0.88 252.26 0.71
128 78.12 1.52 78.12 0.75 126.13 0.74
256 39.06 1.59 39.06 0.71 63.06 0.65
Figure 5.22 – Moyenne (moy) et écart type (ect) du nombre d’arêtes obtenu pour chaque
processeurs suite au partitionnement.
ger en une itération de temps et pour un unique DPMap, donc une unique quantité de
la simulation. Cette table représente également l’écart type maximal obtenus par rapport
à cette valeur moyenne. Ainsi, de nouveau, plus l’écart type est proche de la valeur
moyenne, plus l’équilibrage des communications effectuées par chaque processeur peut
être considéré comme mauvais. On peut observer ici que l’équilibrage de charge en terme
de communications est assez mauvais dans ce partitionnement. Cependant l’équilibrage
des communications n’est généralement pas considéré dans les problèmes de partitionnements,
où l’on cherche plutôt à minimiser le nombre total de communications (pour le
partitionnement de graphes) ou le volume total de communications (pour le partitionnement
d’hypergraphes). Tout ce que l’on peut conclure de ce résultat est que tous les
processeurs n’ont pas la même charge de communications à effectuer et que cette charge
peut même aller du simple au double. Cela explique peut être partiellement l’accélération
obtenue dans la figure 5.15, sans recouvrement des communications par les calculs.
Arbre 1 Arbre 2 Arbre 3
Nb processeurs moy ect moy ect moy ect
4 616.0 168.09 112.0 53.66 148.0 76.84
8 598.0 377.95 98.0 38.31 128.0 42.14
16 437.0 214.77 107.0 42.41 128.0 49.79
32 390.5 238.87 86.5 40.81 135.0 61.18
64 327.0 214.73 95.75 40.02 111.5 42.33
128 266.12 154.3 94.25 31.29 107.5 39.66
256 209.19 113.43 84.25 31.34 94.0 32.52
Figure 5.23 – Moyenne (moy) et écart type (ect) du nombre d’octets à échanger pour chaque
processeur, pour chaque DPMap et pour une unique itération de temps de la simulation, suite
au partitionnement des arbres de la table 5.8.
Afin de mieux percevoir la charge d’échanges de chaque processeur dans une simulation
complète, prenons l’exemple de la simulation artérielle décrite dans la section 5.4, en
utilisant les arbres de la table 5.8. Dans la simulation artérielle, il y a onze DPMap néces-5.5. Partitionnement de réseaux 141
sitant des échanges de données, et quatre-vingt mille itérations de temps. La table 5.24
représente alors le nombre moyen total d’octets échangés pour chaque processeur lors de
la simulation complète.
Nb processeurs Arbre 1 Arbre 2 Arbre 3
4 542 Mo 98.6 Mo 130.2 Mo
8 526.2 Mo 86.2 Mo 112.6 Mo
16 384.6 Mo 94.2 Mo 112.6 Mo
32 343.6 Mo 76.1 Mo 118.8 Mo
64 287.8 Mo 84.3 Mo 98.1 Mo
128 234.2 Mo 82.9 Mo 94.6 Mo
256 184.1 Mo 74.1 Mo 82.7 Mo
Figure 5.24 – Moyenne du nombre d’octets total à échanger pour chaque processeur, dans le
cadre de la simulation artérielle de la section 5.4, en utilisant les arbres de la table 5.8.
Les résultats obtenus sont donc relativement bons en terme d’équilibrage de charge.
Et les performances obtenues sur bloodflow-SkelGIS sont elles aussi relativement bonnes
grâce à la mise en place d’un recouvrement des communications par les calculs, comme
nous l’avons vu dans la section 5.4.3. Cependant, les performances sont également limitées
par les choix qui ont été effectués pour mettre en place un partitionnement. Tout d’abord,
notre partitionnement actuel ne s’adresse qu’aux réseaux de type DAG. De plus, nous ne
profitons pas de l’efficacité et de l’expertise des partitionneurs existants comme Scotch ou
METIS, ni de leur version parallélisées. Enfin, en dupliquant les nœuds, nous ne traitons
pas le véritable problème de partitionnement des réseaux et nous ne pouvons garantir son
efficacité dans tous les types de simulations et sur tous les types de graphes. En effet, nous
avons considéré que les calculs sur les nœuds étaient presque négligeables par rapport
aux calculs sur les arêtes, ce qui n’est pas toujours le cas suivant les simulations. De plus,
les expériences mesurées dans cette section semblent montrer la nécessité d’améliorer le
partitionnement du prototype de SkelGIS. C’est la raison pour laquelle nous avons étudié
d’autres méthodes de partitionnement, présentées dans la suite de cette thèse.
5.5.2 Partitionnement avec Mondriaan
Des travaux plus récents sur SkelGIS s’intéressent au véritable problème de partitionnement
posé par les réseaux, sans duplication des nœuds. Nous étudions ici la formalisation
du problème et deux méthodes de partitionnement. Notons que le but de ce travail
est également de retourner vers une définition plus générale des réseaux. Nous prenons
en compte ici tout type de réseau, et non plus seulement ceux pouvant être représentés
par un graphe dirigé acyclique. En étudiant le véritable problème de partitionnement,
nous évitons également la duplication des nœuds sur les processeurs.142 Chapitre 5. SkelGIS pour des simulations sur réseaux
5.5.2.1 Formalisation du problème de partitionnement des réseaux
Comme expliqué précédemment, une simulation sur un réseau implique le calcul de
deux schémas numériques différents, reliés entre eux par le réseau, créant une certaine
dépendance de calcul aux bordures physiques, Nout. À partir de deux schémas numériques
explicites, il est donc possible d’obtenir un ensemble explicite lui aussi, ou à l’inverse
implicite. L’ensemble général obtenu dépend de la simulation en elle-même et ne peut
être connu à l’avance. Toutefois, il est possible de définir de façon générale, comme
nous l’avons décrit dans la section 5.1, quatre super-étapes dans une simulation sur les
réseaux, que nous rappelons ici. Notons T1 et T2 les deux types d’éléments du réseau
qui peuvent être indifféremment associés aux nœuds ou aux arêtes. Une itération t d’une
simulation sur un réseau est alors, de façon générale, représentée par les quatre super-
étapes suivantes :
1. Communication des éléments T1 à t − 1
2. Calcul des éléments T2 à t
3. Communication des éléments T2 à t − 1 ou/et t
4. Calcul des éléments T1 à t
Il est possible de représenter l’ensemble des cas de simulation sur les réseaux en parallèle
avec cette définition en quatre super-étapes BSP. Le partitionnement consiste donc en
deux problèmes, tout d’abord équilibrer la charge de travail entre les processeurs pour
les étapes 2 et 4, et réduire au maximum le volume de communications nécessaire aux
étapes 1 et 3. On peut noter que l’étape 2 consiste à calculer T2 et a besoin au préalable
des communications des éléments T1 pour effectuer ce calcul. On parlera alors d’une
communication de T1 vers T2. L’équilibrage de charge consiste donc à équilibrer les
deux types d’éléments T1 et T2, et la réduction du volume de communication consiste à
minimiser les communications de T1 à T2 et de T2 à T1. Ces deux contraintes doivent
être résolues de la même façon quelque soit leur ordre d’apparition et quelque soit donc
l’association des types T1 et T2 aux nœuds et aux arêtes. Pour simplifier la suite de
ce travail, nous étudierons le cas précis de la simulation de l’écoulement du sang dans
les artères qui a été décrite dans la section 5.4, et dont les quatre super-étapes sont les
suivantes :
1. Communication des arêtes à t − 1
2. Calcul des nœuds à t
3. Communication des nœuds à t
4. Calcul des arêtes à t
La particularité du problème de partitionnement pour les réseaux est donc que des
calculs et des communications sont effectués à la fois sur les arêtes et sur les nœuds du
graphe. Si les équilibrages de charge des nœuds et des arêtes peuvent être traités de
façon distincte, les communications engendrées par leur affectation sont, en revanche,
non-distinctes et reliées par le réseau. Tout d’abord, afin de pouvoir traiter à la fois le
partitionnement des nœuds et des arêtes en utilisant les outils classiques, qui ne traitent5.5. Partitionnement de réseaux 143
que les nœuds d’un graphe, nous commençons par changer la représentation du graphe
du réseau. Le graphe G = (V, E) avec n nœuds v0, . . . , vn−1 et m arêtes e0, . . . , em−1 est
transformé en un nouveau graphe G0 = (V
0
, E0
) où |V
0
| = n + m et |E0
| = 2m. Pour
chaque arête ek = (vi
, vj ) ∈ E, un nœud v
0
n+k
est ajouté, et l’arête ek est coupée en
deux arêtes e
0
k = (vi
, v0
n+k
) et e
0
2k = (v
0
n+k
, vj ). Le graphe G0
représente donc les arêtes
du graphe G comme des nœuds supplémentaires, tout en conservant la connectivité du
graphe G. La figure 5.25 illustre un graphe G et le graphe associé G0
. Notons que G0
Figure 5.25 – Transformation du graphe G d’un réseau en graphe G0
.
est un graphe biparti avec deux sous-ensembles de nœuds, les rouges et les bleus. Nous
n’avons alors dans ce graphe que des liens du type bleu-rouge mais pas de liens du type
bleu-bleu ou rouge-rouge. Dans ce nouveau graphe G0
, les étapes de communications 1
et 3, représentées dans les figures 5.26(a) et 5.26(b), deviennent :
— Communication des nœuds rouges vers les nœuds bleus
— Communication des nœuds bleus vers les nœuds rouges
(a) Étape de communication
1, des nœuds rouges
vers les nœuds bleus de G
0
.
(b) Étape de communication 3,
des nœuds bleus vers les nœuds
rouges de G
0
.
Figure 5.26 – Étapes de communication 1 et 3.144 Chapitre 5. SkelGIS pour des simulations sur réseaux
Dans la suite, les nœuds bleus représenteront donc les nœuds du réseau initial G, et
les nœuds rouges les arêtes du réseau initial G. Étant donné le graphe biparti G0 ainsi
défini, l’ensemble des nœuds V
0 du graphe est composé de deux parties V
r
et V
b
, telles
que V
0 = V
r ∪V
b
et V
r ∩V
b = ∅. Le problème de partitionnement du graphe G0
consiste
alors à partitionner V
r
et V
b
en p parties telles que V
r =
S
i
V
r
i
et V
b =
S
i
V
b
i
et telles
que pour i 6= j ∈ J0, pJ, V r
i ∩V
r
j = ∅ et V
b
i ∩V
b
j = ∅. Le partitionnement de G0 ainsi défini
doit minimiser le volume de communications entre les processeurs, tout en répondant
aux deux contraintes d’équilibrage de charge suivantes :
ω(V
r
i
) ≤ (1 + )
ω(V
r
)
p
(5.9)
ω(V
b
i
) ≤ (1 + )
ω(V
b
)
p
(5.10)
où ω(A) représente le poids total des nœuds d’un ensemble A ∈ V , et où représente le
pourcentage de tolérance dans l’équilibrage de charge.
Pour résoudre ce problème de partitionnement, le modèle de partitionnement d’hypergraphe
(défini dans la section 2.3 de cette thèse) est utilisé sur le graphe G0
. Deux
méthodes de partitionnement différentes sont étudiées et sont détaillées dans les deux
sections suivantes.
5.5.2.2 Méthode à partitionnement unique
La première méthode qui a été étudiée, appelée la méthode à partitionnement unique,
est composée de deux étapes que nous allons expliquer en détails dans cette partie :
1. L’étape de communication des nœuds bleus vers les nœuds rouges est, tout d’abord,
transformée en un problème de partitionnement d’hypergraphe, qui permet de distribuer
les nœuds rouges sur les différents processeurs.
2. Une heuristique est ensuite appliquée pour distribuer les nœuds bleus sur les processeurs,
tout en prenant en compte la distribution des nœuds rouges qui a été faite
au préalable.
Afin d’effectuer la première étape de cette méthode, une matrice A de taille m×n est
créée et a pour but de représenter les communications des nœuds bleus vers les nœuds
rouges. Les lignes de la matrice A représentent les nœuds bleus du graphe G0
, et les
colonnes de la matrice A représentent, quant à elles, les nœuds rouges du graphe G0
.
Si une communication est nécessaire d’un nœud bleu vers un nœud rouge, une valeur
1 est placée dans A aux coordonnées correspondantes. Si l’on distribue les colonnes
de la matrice A (les nœuds rouges) aux processeurs, le volume de communication sera
minimisé si et seulement si les éléments non nuls de chaque ligne sont répartis sur le
minimum de processeurs différents. La distribution des colonnes de la matrice A revient
en fait à procéder à un partitionnement d’hypergraphe suivant une dimension. Il s’agit
donc du partitionnement row-net model [29], décrit dans l’état de l’art de cette thèse
dans la section 2.3, qui consiste à distribuer les colonnes de la matrice A (les nœuds de5.5. Partitionnement de réseaux 145
l’hypergraphe), tout en cherchant à minimiser le nombre de coupures sur une ligne de A
(une hyper-arête). La figure 5.27 donne un exemple de réseau, la matrice A qui lui est
associée, et enfin l’hypergraphe Hr(A) qui lui est également associé.
0 1
2 3 4
5 6
0 1 2
3 4 5 6
0
1
2
3
4
5
6
0 1 2 3 4 5 6
1 1
1
1 1
1 1 1 1
1
1 1
1 1
0 0 0 0 0
0 0 0 0 0 0
0 0 0 0 0
0 0 0
0 0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 1 2
3 4
5
6
0 2
3 51
6 4
Figure 5.27 – Exemple de réseau G0 avec la matrice A et les hypergraphes Hr et Hc qui y sont
associés
Le partitionnement de l’hypergraphe Hr associé à un réseau initial G peut être effectué
avec le partitionneur Mondriaan [120] dans un mode à une dimension. Les nœuds
rouges de G0
, c’est-à-dire les arêtes du réseau, sont alors distribués en respectant la
contrainte (5.9) et en cherchant à minimiser les communications des nœuds bleus vers
les nœuds rouges.
Une fois cette première étape effectuée, le partitionnement de V
r
est terminé et
respecte la contrainte d’équilibrage (5.9). Les nœuds rouges et bleus de G0
étant connectés
par le réseau, il est important de trouver une heuristique permettant de distribuer les
nœuds bleus en tenant compte de la distribution des nœuds rouges, afin de minimiser le
nombre de communications nécessaires. Le problème de partitionnement pour les nœuds
bleus est illustré dans la figure 5.28. En effet, étant donné quatre nœuds rouges déjà
distribués aux processeurs p0, p1 et p2, comment choisir parmi ces trois processeurs celui
qui sera attribué au nœud bleu.
Afin d’expliquer l’heuristique mise en place pour le partitionnement des nœuds bleus,
quelques définitions sont nécessaires. Pour un nœud v ∈ V
b
, deg(v) représente le nombre
de nœuds rouges adjacents à v dans G0
, ou en d’autres termes le nombre d’arêtes adjacentes
à v dans G. deg(v, Pi) représente le nombre de nœuds rouges adjacents à v et
qui ont été distribués au processeur Pi
. L’heuristique choisit d’attribuer le nœud v au
processeur Pi de façon à maximiser deg(v, Pi), et de façon à vérifier la contrainte d’équilibrage
(5.10). Notons que si il existe i, j ∈ J0, pJ qui satisfont deg(v, Pi) = deg(v, Pj ),
alors P(v) = Pi si et seulement si ω(V
b
i
) < ω(V
b
j
), sinon P(v) = Pj . Grâce à cette
heuristique les nœuds bleus de G0
sont partitionnés en tentant de respecter la contrainte
d’équilibrage de charge (5.10), tout en minimisant les communications des nœuds rouges146 Chapitre 5. SkelGIS pour des simulations sur réseaux
P0
P0
P1
P2
Quel P {P0,P1,P2} ?
Figure 5.28 – Problème de partitionnement pour les nœuds bleus de G0
.
vers les nœuds bleus. En effet, en reprenant l’exemple de la figure 5.28, l’heuristique
décrite choisie idéalement d’assigner le nœud au processeur P0. Dans ce cas seules deux
communications des nœuds rouges vers les nœuds bleus sont nécessaires. À l’inverse, si un
autre processeur avait été choisi, trois communications des nœuds rouges vers les nœuds
bleus auraient été nécessaires.
5.5.2.3 Méthode à partitionnement double
La deuxième méthode de partitionnement qui a été étudiée, appelée la méthode à
partitionnement double, est elle composée des trois étapes suivantes :
1. Tout comme dans la première méthode, l’étape de communication des nœuds bleus
vers les nœuds rouges est transformée en un problème de partitionnement d’hypergraphe,
qui permet de distribuer les nœuds rouges sur les différents processeurs.
2. L’étape de communication des nœuds rouges vers les nœuds bleus est ensuite transformée
en un problème de partitionnement d’hypergraphe, qui permet de distribuer
les nœuds bleus à leur tour.
3. Une dernière étape de permutation est mise en place pour permettre de réaffecter
certains processeurs à certaines partitions et ainsi d’améliorer la minimisation du
nombre de communications.
La première étape de cette méthode est exactement la même que la première étape
de la méthode à partitionnement unique décrite précédemment. Les communications
des nœuds bleus vers les nœuds rouges y sont exprimées dans une matrice creuse A
correspondant à la matrice d’incidence du graphe G. Puis la matrice A est transposée en
un hypergraphe Hr, où les lignes de A représentent les hyper-arêtes et les colonnes les
nœuds de Hr. Un partitionnement row-net à une dimension est effectué sur l’hypergraphe
Hr avec le partitionneur Mondriaan [120].
La deuxième étape de cette méthode est, quant à elle, la transposée de la première
étape. Les communications des nœuds rouges vers les nœuds bleus peuvent être représentées
par la matrice AT
, transposée de la matrice A. Cette matrice pourrait à son tour être
transformée en un hypergraphe Hr(AT
) sur lequel serait appliqué un partitionnement à5.5. Partitionnement de réseaux 147
une dimension de type row net. Toutefois, il est plus simple de conserver la matrice A
mais d’y appliquer un partitionnement à une dimension de type column net sur un hypergraphe
noté Hc(A) = Hr(AT
) qui va, de la même façon, partitionner les nœuds bleus
et réduire les communications des nœuds rouges vers les nœuds bleus. La figure 5.27
illustre ces deux premières étapes du partitionnement et les deux hypergraphes Hr(A) et
Hc(A).
La différence principale entre la première et la deuxième étape de cette méthode
est le nombre de nœuds présents dans chaque hyper-arête. En effet, dans la première
étape, le nombre de nœuds dans chaque hyper-arête est égal au degré du nœud bleu
correspondant. Dans la deuxième étape, en revanche, le nombre de nœuds dans chaque
hyper-arête est égal à deux puisque chaque nœud rouge de G0
est uniquement relié à
deux nœuds bleus. Par conséquent, le partitionnement d’hypergraphe row-net (columnnet)
appliqué dans la deuxième étape de la méthode est équivalent à un partitionnement
de graphe standard où le nombre d’arêtes coupées doit être minimisé. Un partitionneur de
graphe pourrait donc être utilisé pour résoudre la deuxième étape de la méthode, comme
par exemple METIS [77] ou Scotch [100]. Toutefois, il est également possible d’utiliser le
même partitionneur d’hypergraphes Mondriaan [120], et ainsi d’en abuser légèrement [53]
tout en payant le coût supplémentaire en temps CPU. En effet, le partitionnement d’un
hypergraphe n’est qu’une généralisation du partitionnement de graphe, cette méthode
peut donc être utilisée pour le partitionnement de graphe mais demandera plus de calculs
du fait de la généralisation qui y est appliquée.
Une fois la première et la deuxième étape effectuées, les nœuds rouges et les nœuds
bleus du graphe G0
sont attribués parmi les processeurs disponibles en respectant les deux
contraintes d’équilibrage (5.9) et (5.10). Toutefois les deux partitionnements qui ont été
effectués sont totalement indépendants et ne tiennent donc pas compte des connections
qui relient les nœuds bleus et les nœuds rouges dans le réseau.
La troisième étape va servir à prendre en compte ces liaisons et ainsi à réduire les communications
engendrées par les deux partitionnements distincts. On obtient donc deux
partitionnements, et l’on souhaite pouvoir réaffecter les processeurs dans ces distributions
afin d’améliorer le volume de communications. Il s’agit donc d’un problème d’affectation
dans un graphe biparti. Ce problème est équivalent à effectuer une permutation des processeurs
de façon optimale. Tout d’abord, nous définissons une matrice W de taille p × p
(p étant le nombre de processeurs) pour exprimer le nombre de communications évitées si
une permutation était effectuée entre deux processeurs donnés. Le calcul de cette matrice
W est basée sur la matrice A, puisque celle-ci représente les communications des nœuds
bleus vers les nœuds rouges, et à l’inverse, en utilisant sa transposée, des nœuds rouges
vers les nœuds bleus.
Dans la matrice A, ai,j = 1 si un nœud bleu i est relié à un nœud rouge j. Une
fois le partitionnement des nœuds bleus et rouges effectués, Φ(i) représente le processeur
attribué pour le nœud bleu i, et Ψ(j) représente le processeur attribué pour le nœud
rouge j. La matrice W est alors définie comme suit
ws,t =
X
i : Φ(i)=s
δi(t) (5.11)148 Chapitre 5. SkelGIS pour des simulations sur réseaux
δi(t) = (
1 si ∃j : ai,j = 1 ∧ Ψ(j) = t
0, sinon.
(5.12)
La taille de la matrice W dépend du nombre de processeurs utilisés et la matrice est
généralement dense. L’élément ws,t de la matrice représente le nombre de communications
évitées, des nœuds bleus vers les nœuds rouges, si le processeur s est permuté avec le
processeur t. La matrice W peut donc être identifiée comme un graphe biparti complet
Gw, illustré dans la figure 5.29. Un moyen de calculer la meilleure permutation possible de
processeurs est alors de calculer le couplage ou l’appariement (ou le matching en anglais)
maximum du graphe biparti complet Gw.
p-1
p-1
W =
ws,t
t
s
0 1 s p-1
0 1 t p-1
ws,t
Figure 5.29 – La matrice W et le graphe biparti complet auquel la matrice peut être identifiée
Gw.
Le couplage maximum de Gw est équivalent à un problème d’assignement qui peut être
résolu en O(p
4
) en utilisant l’algorithme Hongrois, publié par Harold Kuhn en 1955 [83] ;
et qui peut également être résolu en O(p
3
) en utilisant une amélioration de cet algorithme.
Si le nombre de processeurs p est grand, des approximations linéaires de cet algorithme
peuvent également être utilisées.
La matrice W représente les communications évitées, mais uniquement pour les communications
des nœuds bleus vers les rouges. Le problème similaire peut être résolu pour
la matrice AT
et permettra de réduire les communications des nœuds rouges vers les
nœuds bleus également. Une matrice W0
est alors calculée n’est pas égale à WT
. Toutefois,
en calculant de la même façon que W la matrice W0
, en utilisant AT au lieu de A,
la valeur w
0
(s, t) de W0
représente le nombre de communications évitées en échangeant
le processeur t avec le processeur s. Afin de maximiser les communications évitées par
permutation, l’algorithme Hongrois doit alors être appliqué sur W = W + (W0
)
T
.
Cette deuxième méthode respecte donc les deux contraintes d’équilibrage (5.9)
et (5.10) en appliquant deux partitionnements d’hypergraphe à une dimension sur la
matrice A. Par la suite, afin de lier les deux distributions obtenues et d’en minimiser le
volume des communications obtenu, un couplage maximum est appliqué sur la matrice
W.5.5. Partitionnement de réseaux 149
5.5.2.4 Résultats
A l’heure actuelle aucun résultat n’a encore été établi pour ces deux nouvelles mé-
thodes de partitionnement des réseaux. Ces nouvelles méthodes s’inscrivent dans un
cadre plus général que l’implémentation actuelle de SkelGIS, puisqu’elles généralisent
le problème aux graphes et non plus seulement aux DAG. Un certain nombre d’adaptations
est en cours pour permettre l’utilisation de ces méthodes de partitionnement,
puis pour les évaluer sur le cas de test présenté dans la section 5.4. Une intuition sur
les résultats attendus est toutefois possible. Il semble, à première vue, que la méthode à
partitionnement simple soit plus intéressante. En effet, elle répond strictement aux deux
contraintes d’équilibrage de charges des équations (5.9) et (5.10), tout comme la méthode
à double partitionnement. Toutefois, elle distribue directement les sommets bleus en tenant
compte des sommets rouges, alors que la méthode à double partitionnement effectue
deux partitionnements totalement indépendants avant d’essayer de les rapprocher par un
matching. Cela laisse penser que malgré le matching effectué la méthode à partitionnement
double ne pourra être aussi efficace, en terme de volume de communications, que
dans la première méthode. Mais essayons de décrire un modèle de coût pour prévoir les
résultats de ces méthodes de façon plus formelle.
Étant donné une simulation sur les réseaux implémentée en SkelGIS, nous pouvons
noter T
i
comp le temps passé dans les calculs pour le processeur i. Étant donné T
i
1comp
et
T
i
2comp
, le temps écoulé dans le calcul des nœuds et des arêtes du réseau (ou vice versa),
pour le processeur i, nous considérons que T
i
comp = T
i
1comp + T
i
2comp
. Les deux méthodes
présentées dans cette section respectent strictement les deux contraintes d’équilibrage
de charges (5.9) et (5.10). Cela signifie que pour tout processeurs i et j, tel que i 6= j,
T
i
1comp ≈ T
j
1comp
et T
i
2comp ≈ T
j
2comp
. On peut alors considérer que le temps total de
calcul de la simulation, Tcomp, est égale à max
0≤i.
HAL Id: tel-01067475
https://tel.archives-ouvertes.fr/tel-01067475
Submitted on 23 Sep 2014
HAL is a multi-disciplinary open access
archive for the deposit and dissemination of scientific
research documents, whether they are published
or not. The documents may come from
teaching and research institutions in France or
abroad, or from public or private research centers.
L’archive ouverte pluridisciplinaire HAL, est
destin´ee au d´epˆot et `a la diffusion de documents
scientifiques de niveau recherche, publi´es ou non,
´emanant des ´etablissements d’enseignement et de
recherche fran¸cais ou ´etrangers, des laboratoires
publics ou priv´es.UNIVERSITÉ PIERRE ET MARIE CURIE
ÉCOLE DOCTORALE INFORMATIQUE, TÉLÉCOMMUNICATIONS ET ÉLECTRONIQUE
Analyse de sécurité de logiciels système
par typage statique
— Application au noyau Linux—
ÉTIENNE MILLON
sous la direction d’Emmanuel Chailloux et de Sarah Zennou
THÈSE
pour obtenir le titre de
Docteur en Sciences
mention Informatique
Soutenue le 10 juillet 2014 devant le jury composé de
Rapporteurs
Sandrine Blazy IRISA
Pierre Jouvelot MINES ParisTech
Directeurs
Emmanuel Chailloux Université Pierre et Marie Curie
Sarah Zennou Airbus Group Innovations
Examinateurs
Gilles Muller Université Pierre et Marie Curie
Vincent Simonet Google
Invité
Olivier Levillain ANSSIi
“
Many C programmers believe that « strong
typing » just means pounding extra hard on
the keyboard.
Peter van den Linden
”ii
REMERCIEMENTS
Enfin la dernière page à écrire ! Combien de fois ai-je entendu que "les remerciements,
c’est le plus facile". Et pourtant, je suis partagé entre la satisfaction d’arriver au bout de ce
travail, et la crainte d’oublier une des nombreuses personnes qui m’ont aidé à y arriver.
Je tiens à commencer par remercier mes encadrants de thèse, Emmanuel Chailloux et
Sarah Zennou. Sans leurs conseils pertinents et leurs nombreuses relectures, je n’aurais pas
pu arriver au bout de ce travail.
Merci également à Sandrine Blazy et à Pierre Jouvelot d’avoir accepté de rapporter mon
manuscrit, et pour leurs remarques qui ont permis d’améliorer sa qualité. Je veux également
remercier les autres membres du jury, Gilles Muller et Vincent Simonet, ainsi qu’Olivier Levillain
qui y a sa place en tant qu’invité.
Si cette aventure a pu être menée à bien, c’est également grâce au travail réalisé par
l’équipe de l’école doctorale, et en particulier à Marylin Galopin et Christian Queinnec qui
ont toujours su m’aider dans cette véritable quête administrative qu’est le doctorat. Je souhaite
d’ailleurs le meilleur à Bertrand Granado pour reprendre les rênes de l’EDITE.
Le pendant du travail de recherche est traditionnellement celui de l’enseignement ; dans
mon cas l’expérience pédagogique a été un peu courte, mais elle a été très agréable grâce aux
équipes enseignantes des cours de Programmation et Données Génériques (LI220, « l’UE des
chefs ») et de Techniques Évenementielles et Réactives (LI357, « l’UE Magnum »).
Ce projet a commencé chez Airbus Group Innovations (alors EADS Innovation Works),
alors que je n’étais qu’un étudiant ingénieur intéressé par la compilation. Merci à Wenceslas
Godard et Charles Hymans de m’avoir offert ce stage, puis de l’étendre à ce projet de thèse.
La suite a été plus quelque peu nébuleuse, et je remercie chaudement Axel Tillequin et toute
l’équipe SE/IT pour la confiance qu’il ont pu m’offrir en m’accueillant dans un cadre exceptionnel
pour un jeune chercheur. Merci une fois de plus à Sarah d’avoir accepté de reprendre
ce projet.
Mes remerciements vont également à une autre équipe qui m’a accueilli pendant ces années
: l’équipe APR, et en particulier sa directrice Michèle Soria. Les différentes thématiques
de recherche abordées dans le couloir on permis d’étendre mes horizons de doctorant. Je remercie
également le personnel administratif qui a facilité mes conditions de travail durant
toutes ces années.
Une partie de ce projet a également été financée par le projet CERCLES². Je remercie en
particulier Pascal Manoury pour l’accueil qu’il a pu m’apporter dans le laboratoire PPS de
Si vous êtes juste là pour l’Université Paris Diderot.
les blagues, ça commence
ici. Le bureau 26-00-325 a participé à sa manière à l’élaboration de ce document. La pause
café et sa pistonnade rituelle annoncée par Alberto ont permis de débattre jour après jour
des avantages de la programmation générique (et Ramzy), des foncteurs applicatifs, de la
meilleure manière d’écrire un interprète Basic en Rust ou de la stratégie qui nous permettrait
d’en venir à bout de ce niveau 5 de Jamestown, le tout bien sûr schémas, rébus et contrepè-
teries à l’appui.
Merci donc à Vivien (dont je n’écorcherai pas le nom — contrairement à d’autres), Philippe
(dont on sait où il se cache), Benjamin (pour son bon goût), Mathias (parce qu’il a la
classe), Guillaume (pour ses deals de café du 9-3, tu me remettras 1kg de rouge t’as vu ?), Aurélien
(le type-classieux de Rochechouart), Jérémie (λ-traître devant l’éternel) et tous ceux
qui sont passés par là un peu moins longtemps.iii
On raconte que la thèse c’est un ascenseur émotionnel. C’est cliché, mais pas complètement
faux. Pour partager les moments agréables et faciliter les moments de doutes, un grand
merci à mes amis qui ont toujours été là. Merci aussi à ma famille qui m’a toujours donné les
moyens de poursuivre mes projets. D’ailleurs sans mes premières lignes de code sur l’Amstrad
CPC 6128+ familial, j’aurais sûrement tourné différemment !
Enfin, merci à toi, Anaïs. Tu es certainement celle qui a vu le plus les coulisses de ce travail
de longue haleine, jouant à la fois le rôle de confidente et d’attachée de presse, en répondant
toujours « Bientôt ! » à la question « Alors Étienne, il soutient quand ? ». Aujourd’hui j’ai ma
réponse : ça se passe le 10 Juillet et vous êtes tous invités.
Spéciale dédicace à Tarpuy, Mato, Lady of the Pad, la dame des crêpes, aux thèmes de Guile
et de l’invité surprise ainsi qu’à toute l’équipe de Final Form Games.
Aucun λ-terme n’a été maltraité lors de la réalisation de ce document.TABLE DES MATIÈRES
Table des matières iv
1 Introduction 1
1.1 Rôle d’un système d’exploitation . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.2 Séparation entre espace noyau et espace utilisateur . . . . . . . . . . . . . . . . 3
1.3 Systèmes de types . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
1.4 Langages . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
1.5 L’analyse statique dans l’industrie aéronautique . . . . . . . . . . . . . . . . . . 7
1.6 De l’avionique à l’informatique d’entreprise . . . . . . . . . . . . . . . . . . . . 9
1.7 Objectifs et contributions de la thèse . . . . . . . . . . . . . . . . . . . . . . . . . 10
1.8 Plan de la thèse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
I Méthodes formelles pour la sécurité 13
2 Systèmes d’exploitation 15
2.1 Architecture physique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
2.2 Tâches et niveaux de privilèges . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
2.3 Appels système . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
2.4 Le Confused Deputy Problem . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
3 Analyses statiques existantes 21
3.1 Taxonomie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
3.2 Méthodes syntaxiques . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
3.3 Analyse de valeurs et interprétation abstraite . . . . . . . . . . . . . . . . . . . . 22
3.4 Typage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
3.5 Langages sûrs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
3.6 Logique de Hoare . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
3.7 Assistants de preuve . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
Conclusion de la partie I 31
II Un langage pour l’analyse de code système : SAFESPEAK 33
4 Syntaxe et sémantique d’évaluation 35
4.1 Notations . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
4.2 Syntaxe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
4.3 Mémoire et valeurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
4.4 Interprète . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
4.5 Opérations sur les valeurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
4.6 Opérations sur les états mémoire . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
4.7 Accesseurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
ivTABLE DES MATIÈRES v
4.8 Contextes d’évaluation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 52
4.9 Valeurs gauches . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 54
4.10 Expressions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55
4.11 Instructions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
4.12 Erreurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58
4.13 Phrases et exécution d’un programme . . . . . . . . . . . . . . . . . . . . . . . . 58
4.14 Exemple . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59
5 Typage 63
5.1 Environnements et notations . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
5.2 Expressions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
5.3 Instructions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68
5.4 Fonctions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69
5.5 Phrases . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69
5.6 Sûreté du typage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70
5.7 Typage des valeurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70
5.8 Propriétés du typage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
5.9 Progrès et préservation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77
6 Extensions de typage 81
6.1 Exemple préliminaire : les entiers utilisés comme bitmasks . . . . . . . . . . . 82
6.1.1 Modifications . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
6.1.2 Exemple : ! x & y . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 84
6.2 Analyse de provenance de pointeurs . . . . . . . . . . . . . . . . . . . . . . . . . 85
6.2.1 Extensions noyau pour SAFESPEAK . . . . . . . . . . . . . . . . . . . . . 86
6.2.2 Extensions sémantiques . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
6.2.3 Insuffisance des types simples . . . . . . . . . . . . . . . . . . . . . . . . 89
6.2.4 Extensions du système de types . . . . . . . . . . . . . . . . . . . . . . . 90
6.2.5 Sûreté du typage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92
Conclusion de la partie II 95
III Expérimentation 97
7 Implantation 99
7.1 NEWSPEAK et chaîne de compilation . . . . . . . . . . . . . . . . . . . . . . . . . 99
7.2 L’outil ptrtype . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102
7.3 Exemple . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
7.4 Performance . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108
8 Étude de cas : le noyau Linux 111
8.1 Spécificités du code noyau . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
8.2 Appels système sous Linux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 112
8.3 Risques . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 113
8.4 Premier exemple de bug : pilote Radeon KMS . . . . . . . . . . . . . . . . . . . . 113
8.5 Second exemple : ptrace sur architecture Blackfin . . . . . . . . . . . . . . . . 115
8.6 Procédure expérimentale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 117vi TABLE DES MATIÈRES
Conclusion de la partie III 123
9 Conclusion 125
9.1 Contributions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 125
9.2 Différences avec C . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 126
9.3 Perspectives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 129
A Module Radeon KMS 133
B Syntaxe et règles d’évaluation 137
B.1 Syntaxe des expressions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 137
B.2 Syntaxe des instructions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 138
B.3 Syntaxe des opérateurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 138
B.4 Contextes d’évaluation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 139
B.5 Règles d’évaluation des erreurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . 139
B.6 Règles d’évaluation des valeurs gauches et expressions . . . . . . . . . . . . . . 140
B.7 Règles d’évaluation des instructions, phrases et programmes . . . . . . . . . . 141
B.8 Règles d’évaluation des extensions noyau . . . . . . . . . . . . . . . . . . . . . . 142
C Règles de typage 143
C.1 Règles de typage des constantes et valeurs gauches . . . . . . . . . . . . . . . . 143
C.2 Règles de typage des opérateurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . 144
C.3 Règles de typage des expressions et instructions . . . . . . . . . . . . . . . . . . 145
C.4 Règles de typage des valeurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 146
C.5 Règles de typage des extensions noyau . . . . . . . . . . . . . . . . . . . . . . . . 146
D Preuves 147
D.1 Composition de lentilles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 147
D.2 Progrès . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148
D.3 Préservation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 153
D.4 Progrès pour les extensions noyau . . . . . . . . . . . . . . . . . . . . . . . . . . 156
D.5 Préservation pour les extensions noyau . . . . . . . . . . . . . . . . . . . . . . . 157
Liste des figures 159
Liste des définitions 161
Liste des théorèmes et lemmes 161
Références web 163
Bibliographie 165C H A P I T R E
1
INTRODUCTION
Communication, audiovisuel, transports, médecine : tous ces domaines se sont transformés
dans les dernières décennies, en particulier grâce à la révolution numérique. En effet
le plus petit appareil électrique contient maintenant des composants matériels programmables.
En 2014, on pense bien sûr aux téléphones portables dont la fonctionnalité et la complexité
les rapprochent des ordinateurs de bureau. Par exemple, le système d’exploitation
Android de Google est fondé sur le noyau Linux, destiné à la base aux micro-ordinateurs.
Le noyau d’un système d’exploitation est chargé de faire l’intermédiaire entre le maté-
riel (processeur, mémoire, périphériques, . . . ) et les applications exécutées sur celui-ci (par
exemple un navigateur web, une calculatrice ou un carnet d’adresses).
Il doit aussi garantir la sécurité de celles-ci : en tant qu’intermédiaire de confiance, le
noyau a un certain nombre de responsabilités et est le seul à avoir accès à certaines informations
sensibles. Il est capital de s’assurer qu’il est bien le seul à pouvoir y accéder. En particulier,
il faut pouvoir vérifier que les requêtes faites par l’utilisateur au noyau ne peuvent pas,
volontairement ou involontairement, détourner ce dernier et lui faire fuiter des informations
confidentielles.
Le problème est que, comme tous les logiciels, les noyaux de système d’exploitation sont
écrits par des humains qui ne sont pas parfaits. Les activités de relecture et de débogage ont
beau prendre la majeure partie du temps de développement, il est facile de laisser passer des
défauts de programmation.
Ces erreurs, ou bugs, peuvent avoir des conséquences dramatiques sur le plan matériel ou
humain. À titre d’exemple, un Airbus A320 embarque près de 10 millions de lignes de code :
il est capital de vérifier que celles-ci ne peuvent pas mettre en danger la sûreté des passagers.
Une technique efficace est de réaliser des tests, c’est-à-dire exécuter le programme sous
un environnement contrôlé. On peut alors détecter des comportements non désirés. Mais
même avec une grande quantité de tests il n’est pas possible de couvrir tous les cas d’utilisation.
Une autre approche est d’analyser le code source du programme avant de l’exécuter et
de refuser de lancer les programmes qui contiennent certaines constructions dangereuses.
C’est l’analyse statique de programmes.
Une des techniques d’analyse statique les plus répandues et les plus simples est le typage
statique, qui consiste à associer, à chaque morceau de programme, une étiquette décrivant
quel genre de valeur sera produite par son évaluation. Par exemple, si n est le nom d’une
variable entière, alors n +2 produira toujours une valeur entière. Cela permet de savoir si les
programmes manipuleront des données incompatibles entre elles.
12 CHAPITRE 1. INTRODUCTION
Pour en revenir aux noyaux de système d’exploitation, ceux-ci manipulent à la fois des
données sensibles et des données provenant du monde extérieur, pour lesquelles on n’a aucune
garantie. On veut pouvoir distinguer ces deux classes de données.
Plus précisément, un des points cruciaux pour garantir l’isolation d’un noyau de système
d’exploitation est de restreindre la manière dont sont traitées les informations provenant des
programmes utilisateur.
Le but de cette thèse est de montrer que le typage statique peut être utilisé pour détecter
et interdire ces manipulations dangereuses.
1.1 Rôle d’un système d’exploitation
Un ordinateur est constitué de nombreux composants matériels : microprocesseur, mé-
moire, et divers périphériques. Et au niveau de l’utilisateur, des dizaines de logiciels permettent
d’effectuer toutes sortes de calculs et de communications. Le système d’exploitation
permet de faire l’interface entre ces deux échelles.
Au cours de l’histoire des systèmes informatiques, la manière de les programmer a beaucoup
évolué. Au départ, les programmeurs avaient accès au matériel dans son intégralité :
toute la mémoire pouvait être accédée, toutes les instructions pouvaient être utilisées.
Néanmoins cela était un peu restrictif, puisque cela ne permet qu’à une personne d’interagir
avec le système. Dans la seconde moitié des années 1960, sont apparus les premiers
systèmes « à temps partagé », permettant à plusieurs utilisateurs de travailler en même temps.
Permettre l’exécution de plusieurs programmes en même temps est une idée importante,
mais elle n’est pas sans difficultés techniques : en effet les ressources de la machine doivent
être aussi partagées entre les utilisateurs et les programmes. Par exemple, plusieurs programmes
vont utiliser le processeur les uns à la suite des autres ; et chaque programme aura
à sa disposition une partie de la mémoire principale, ou du disque dur.
Si plusieurs programmes s’exécutent de manière concurrente sur le même matériel, il
faut s’assurer que l’un ne puisse pas écrire dans la mémoire de l’autre, et aussi que les deux
n’utilisent pas la carte réseau en même temps. Ce sont des rôles du système d’exploitation.
Ainsi, au lieu d’accéder directement au matériel via des instructions de bas niveau, les
programmes communiquent avec le noyau, qui centralise donc les appels au matériel, et abstrait
certaines opérations.
Par exemple, comparons ce qui se passe concrètement lors de la copie de données depuis
un cédérom ou une clef USB.
• Dans le cas du cédérom, il faut interroger le bus SATA, interroger le lecteur sur la pré-
sence d’un disque dans le lecteur, activer le moteur, calculer le numéro de trame des
données sur le disque, demander la lecture, puis déclencher une copie de la mémoire.
• Avec une clef, il faut interroger le bus USB, rechercher le bon numéro de périphérique,
le bon numéro de canal dans celui-ci, lui appliquer une commande de lecture au bon
numéro de bloc, puis copier la mémoire.
Ces deux opérations, bien qu’elles aient la même intention (copier de la mémoire depuis
un périphérique amovible), ne sont pas effectuées en extension de la même manière. C’est
pourquoi le système d’exploitation fournit les notions de fichier, lecteur, etc : le programmeur
n’a plus qu’à utiliser des commandes de haut niveau (« monter un lecteur », « ouvrir un
fichier », « lire dans un fichier ») et, selon le type de lecteur, le système d’exploitation effectuera
les actions appropriées.
En résumé, un système d’exploitation est l’intermédiaire entre le logiciel et le matériel,
et en particulier est responsable de la gestion de la mémoire, des périphériques et des pro-1.2. SÉPARATION ENTRE ESPACE NOYAU ET ESPACE UTILISATEUR 3
cessus. Les détails d’implantation ne sont pas présentés à l’utilisateur ; à la place, il manipule
des abstractions, comme la notion de fichier. Pour une explication détaillée du concept de
système d’exploitation ainsi que des cas d’étude, on pourra se référer à [Tan07].
1.2 Séparation entre espace noyau et espace utilisateur
Puisque le noyau est garant d’une utilisation sûre du matériel, il ne doit pas pouvoir être
manipulé directement par l’utilisateur ou les programmes exécutés. Ainsi, il est nécessaire de
mettre en place des protections entre les espaces noyau et utilisateur.
Au niveau matériel, on utilise la notion de niveaux de privilèges pour déterminer s’il est
possible d’exécuter une instruction.
D’une part, le processeur contient un niveau de privilège intrinsèque. D’autre part,
chaque zone mémoire contenant du code ou des données possède également un niveau de
privilège minimum nécessaire. L’exécution d’une instruction est alors possible si et seulement
si le niveau de privilège du processeur est supérieur à celui de l’instruction et des opé-
randes mémoires qui y sont présentes 1.
Par exemple, supposons qu’un programme utilisateur contienne l’instruction « déplacer
le contenu du registre EAX vers l’adresse mémoire a », où a fait partie de l’espace mémoire de
l’utilisateur. Alors aucune erreur de protection mémoire n’est déclenchée.
Ainsi, pour une instruction manipulant des données en mémoire, les accès possibles sont
décrits dans le tableau suivant. En cas d’impossibilité, une erreur se produit et l’exécution
s’arrête. Par exemple, l’avant-dernière ligne indique que, si un programme tente de lire une
variable du noyau, celui-ci sera arrêté par une exception.
Mode du processeur Privilège (code) Privilège (données) Accès possible
Noyau Noyau Noyau Oui
Noyau Noyau Utilisateur Oui
Noyau Utilisateur Noyau Oui
Noyau Utilisateur Utilisateur Oui
Utilisateur Noyau Noyau Non
Utilisateur Noyau Utilisateur Non
Utilisateur Utilisateur Noyau Non
Utilisateur Utilisateur Utilisateur Oui
En plus de cette vérification, certains types d’instructions sont explicitement réservés au
mode le plus privilégié : par exemple les lectures ou écritures sur des ports matériels, ou celles
qui permettent de définir les niveaux de privilèges des différentes zones mémoire.
Comme les programmes utilisateur ne peuvent pas accéder à ces instructions de bas niveau,
ils sont très limités dans ce qu’ils peuvent faire. En utilisant seulement les seules instructions
non privilégiées, on peut uniquement réaliser des calculs, sans réaliser d’opérations
visibles depuis l’extérieur du programme.
Pour utiliser le matériel ou accéder à des abstractions de haut niveau (comme créer un
nouveau processus), ils doivent donc passer par l’intermédiaire du noyau. La communication
entre le noyau et les programmes utilisateur est constituée du mécanisme des appels système.
1. Ici « supérieur » est synonyme de « plus privilégié ». Dans l’implantation d’Intel présentée dans le chapitre 2,
les niveaux sont numérotés de 0 à 3, où le niveau 0 est le plus privilégié.4 CHAPITRE 1. INTRODUCTION
Lors d’un appel système, une fonction du noyau est invoquée (en mode noyau) avec des
paramètres provenant de l’utilisateur. Il faut donc être particulièrement précautionneux dans
le traitement de ces données.
Par exemple, considérons un appel système de lecture depuis un disque : on passe au
noyau les arguments (d,o,n,a) où d est le nom du disque, o (pour offset) l’adresse sur le
disque où commencer la lecture, n le nombre d’octets à lire et a l’adresse en mémoire où
commencer à stocker les résultats.
Dans le cas d’utilisation prévu, le noyau va copier la mémoire lue dans a. Le processeur
est en mode noyau, en train d’exécuter une instruction du noyau manipulant des données
utilisateur. D’après le tableau de la page 3, aucune erreur ne se produit.
Mais même si ce cas ne produit pas d’erreur à l’exécution, il est tout de même codé de
manière incorrecte. En effet, si on passe à l’appel système une adresse a faisant partie de
l’espace noyau, que se passe-t-il ?
L’exécution est presque identique : au moment de la copie on est en mode noyau, en train
d’exécuter une instruction du noyau manipulant des données noyau. Encore une fois il n’y a
pas d’erreur à l’exécution.
On peut donc écrire n’importe où en mémoire. De même, une fonction d’écriture sur un
disque (et lisant en mémoire) permettrait de lire de la mémoire du noyau. À partir de ces
primitives, on peut accéder aux autres processus exécutés, ou détourner l’exécution vers du
code arbitraire. L’isolation est totalement brisée à cause de ces appels système.
La cause de ceci est qu’on a accédé à la mémoire en testant les privilèges du noyau au lieu
de tester les privilèges de celui qui a fait la requête (l’utilisateur). Ce problème est connu sous
le nom de confused deputy problem [Har88].
Pour implanter un appel système, il est donc nécessaire d’interdire le déréférencement
direct des pointeurs dont la valeur peut être contrôlée par l’utilisateur. Dans le cas du passage
par adresse d’un argument, il aurait fallu vérifier à l’exécution que celui-ci a bien les mêmes
privilèges que l’appelant.
Il est facile d’oublier d’ajouter cette vérification, puisque le cas « normal » fonctionne.
Avec ce genre d’exemple on voit comment les bugs peuvent arriver si fréquemment et pourquoi
il est aussi capital de les détecter avant l’exécution.
1.3 Systèmes de types
La plupart des langages de programmation incorporent la notion de type, dont un des
buts est d’empêcher de manipuler des données incompatibles entre elles.
En mémoire, les seules données qu’un ordinateur manipule sont des nombres. Selon les
opérations effectuées, ils seront interprétés comme des entiers, des adresses mémoire ou des
caractères. Pourtant il est clair que certaines opérations n’ont pas de sens : par exemple, multiplier
un nombre par une adresse ou déréférencer le résultat d’une division sont des comportements
qu’on voudrait pouvoir empêcher.
En un mot, le but du typage est de classifier les objets et de restreindre les opérations possibles
selon la classe d’un objet : en somme, « ne pas ajouter des pommes et des oranges ». Le
modèle qui permet cette classification est appelé système de types et est en général constitué
d’un ensemble de règles de typage, comme « un entier plus un entier égale un entier ».
Typage dynamique Dans ce cas, chaque valeur manipulée par le programme est décorée
d’une étiquette définissant comment interpréter la valeur en question. Les règles de typage
sont alors réalisées à l’exécution. Par exemple, l’opérateur « + » vérifie que ses deux opérandes
ont une étiquette « entier », et construit alors une valeur obtenue en faisant l’addition des1.3. SYSTÈMES DE TYPES 5
deux valeurs, avec une étiquette « entier ». C’est ce qui se passe par exemple dans le langage
Python [☞4].
Typage statique Dans ce cas on fait les vérifications à la compilation. Pour vérifier ceci, on
donne à chaque fonction un contrat comme « si deux entiers sont passés, et que la fonction
renvoie une valeur, alors cette valeur sera un entier ». Cet ensemble de contrats peut être
vérifié statiquement par le compilateur, à l’aide d’un système de types statique.
Par exemple, on peut dire que l’opérateur « + » a pour type (INT, INT) → INT. Cela veut dire
que, si on lui passe deux entiers (INT, INT), alors la valeur obtenue est également un entier.
A contrario, si autre chose qu’un entier est passé à cet opérateur, le programme ne compile
pas.
Typage fort ou faible Indépendamment du moment où est faite cette analyse, on peut avoir
plus ou moins de garanties sur les programmes sans erreurs de typage. En poussant à l’extrême,
les systèmes de types forts garantissent que les valeurs ont toujours le type attendu.
Avec du typage statique, cela permet d’éliminer totalement les tests de typage à l’exécution.
Mais souvent ce n’est pas le cas, car il peut y avoir des constructions au sein du langage qui
permettent de contourner le système de types, comme un opérateur de transtypage. On parle
alors de typage faible.
Polymorphisme Parfois, il est trop restrictif de donner un unique type à une fonction. Si
on considère une fonction ajoutant un élément à une liste, ou une autre triant un tableau en
place, leur type doit-il faire intervenir le type des éléments manipulés ?
En première approximation, on peut imaginer fournir une version du code par type de
données à manipuler. C’est la solution retenue par le langage C, ou par les premières versions
du langage Pascal, ce qui rendait très difficile l’écriture de bibliothèques [Ker81]. On parle
alors de monomorphisme.
Une autre manière de procéder est d’autoriser plusieurs fonctions à avoir le même nom,
mais avec des types d’arguments différents. Par exemple, on peut définir séparément l’addition
entre deux entiers, entre deux flottants, ou entre un entier et un flottant. Selon les informations
connues à la compilation, la bonne version sera choisie. C’est ainsi que fonctionnent
les opérateurs en C++. On parle de polymorphisme ad hoc, ou de surcharge.
Une autre technique est de déterminer la fonction appelée non pas par le type de ses
arguments, mais par l’objet sur lequel on l’appelle. Cela permet d’associer le comportement
aux données. On parle alors de polymorphisme objet. Dans ce cas, celui-ci repose sur le soustypage
: si A1 et A2 sont des sous-types de B, on peut utiliser des valeurs de type A1 ou A2 là
où une valeur de type B est attendue. Dans ce cas, la fonction correspondante sera appelée.
La dernière possibilité est le polymorphisme paramétrique, qui consiste à utiliser le
même code quel que soit le type des arguments. Dans ce cas, on utilise une seule fonction
pour traiter une liste d’entiers ou une liste de flottants, par exemple. Au lieu d’associer à
chaque fonction un type, dans certains cas on lui associe un type paramétré, instanciable
en un type concret. Dans le cas des fonctions de traitement de liste, l’idée est que lorsqu’on
ne touche pas aux éléments, alors le traitement est valable quel que soit leur type. Cette technique
a été décrite en premier dans [Mil78].
Pour un tour d’horizon de différents systèmes de types statiques, avec en particulier du
polymorphisme, on pourra se référer à [Pie02].6 CHAPITRE 1. INTRODUCTION
1.4 Langages
Le système Unix, développé à partir de 1969, a tout d’abord été développé en assembleur
sur un mini-ordinateur PDP-7, puis a été porté sur d’autres architectures matérielles. Pour
aider ce portage, il a été nécessaire de créer un « assembleur portable », le langage C [KR88,
ISO99]. Son but est de fournir des abstractions au dessus du langage d’assemblage. Les structures
de contrôle (if, while, for) permettent d’utiliser la programmation structurée, c’est-
à-dire en limitant l’utilisation de l’instruction goto. Les types de données sont également
abstraits de la machine : ainsi, int désigne un entier machine, indépendamment de sa taille
concrète. Son système de types, bien que statique (il peut y avoir des erreurs de typage à la
compilation), est assez rudimentaire : toutes les formes de transtypage sont acceptées, certaines
conversions sont insérées automatiquement par le compilateur, et la plupart des abstractions
fournies par le langage sont perméables. Le noyau Linux est écrit dans un dialecte
du langage C. Le noyau du système Mac OS X d’Apple est également un dérivé d’Unix, et est
donc aussi écrit dans ce langage.
Néanmoins ce langage n’est pas facile à analyser, car il est conçu pour être facilement écrit
par des programmeurs humains. Certaines constructions sont ambigües 2, et de nombreux
comportements sont implicites 3.
Si on veut analyser des programmes, il est plus pratique de travailler sur une représentation
intermédiaire plus simple afin d’avoir moins de traitements dupliqués. Dans ce cas on
ajoute une phase préliminaire à l’analyse, qui consiste à convertir le code à étudier vers cette
représentation. On présente quelques langages qui peuvent servir ce rôle :
Middle-ends
Les premiers candidats sont bien entendu les représentations intermédiaires utilisées
dans les compilateurs C. Elles ont l’avantage d’accepter, en plus du C standard, les diverses
extensions (GNU, Microsoft, Plan9) utilisées par la plupart des logiciels. En particulier, le
noyau Linux repose fortement sur les extensions GNU.
GCC utilise une représentation interne nommée GIMPLE [Mer03]. Il s’agit d’une structure
d’arbre écrite en C, reposant sur de nombreuses macros afin de cacher les détails d’implantation
interne pouvant varier entre deux versions. Cette représentation étant réputée difficile
à manipuler, le projet MELT [Sta11] permet de générer un plugin de compilateur écrit dans
un dialecte de Lisp.
LLVM [LA04] est un compilateur développé par la communauté open-source puis sponsorisé
par Apple. À la différence de GCC, sa base de code est écrite en C++. Il utilise une repré-
sentation intermédiaire qui peut être manipulée sous forme d’une structure de données C++,
d’un fichier de code-octet compact, ou textuelle.
Cmm est une représentation interne utilisée pour la génération de code lors de la compilation
d’OCaml [LDG+10, CMP03], et disponible dans les sources du compilateur (il s’agit
donc d’une structure de données OCaml). Ce langage a l’avantage d’être très restreint, mais
2. Selon qu’il existe un type nommé a, l’expression (a)-(b) sera interprétée comme le transtypage de -(b)
dans le type a, ou la soustraction des deux expressions (a) et (b).
3. Par exemple, une fonction acceptant un entier long peut être appelée avec un entier de taille plus petite.
Celui-ci sera alors converti implicitement.1.5. L’ANALYSE STATIQUE DANS L’INDUSTRIE AÉRONAUTIQUE 7
malheureusement il n’existe pas directement de traducteur permettant de compiler C vers
Cmm.
C- - [PJNO97] [☞1], dont le nom est inspiré du précédent, est un projet qui visait à unifier
les langages intermédiaires utilisés par les compilateurs. L’idée est que, si un front-end peut
émettre du C- - (sous forme de texte), il est possible d’obtenir du code machine efficace. Le
compilateur Haskell GHC, par exemple, utilise une représentation intermédiaire très similaire
à C- -.
Langages intermédiaires ad hoc
Comme le problème de construire une représentation intermédiaire adaptée à une analyse
statique n’est pas nouveau, plusieurs projets ont déjà essayé d’y apporter une solution.
Puisqu’ils sont développés en parallèle des compilateurs, le support des extensions est en
général moins important dans ces langages.
CIL [NMRW02] est une représentation en OCaml d’un programme C, développée depuis
2002. Grâce à un mécanisme de plugins, elle permet de prototyper rapidement des analyses
statiques de programmes C.
CompCert C, Clight et Cminor sont des langages intermédiaires utilisés dans Compcert,
un compilateur certifié pour C [BDL06, AB07]. C’est-à-dire que les transformations sémantiques
sont faites de manière prouvée. Ces langages intermédiaires sont utilisés pour les passes
de front-end et de middle-end.
1.5 L’analyse statique dans l’industrie aéronautique
En face du problème théorique et technique décrit dans la section 1.2, il faut mettre en
perspective les problématiques industrielles liées à celui-ci. Les travaux présentés ici ont en
effet été réalisés dans l’équipe de sécurité et sûreté logicielle d’EADS Innovation Works, dans
le cadre d’une convention industrielle de formation par la recherche (CIFRE).
Aujourd’hui, la réussite de nombreuses missions dépend de logiciels dont la taille est de
plus en plus grande. Ainsi, en cas de fautes dans ce genre de logiciel, on peut se retrouver
face à de grands impacts économiques, voire risquer des vies humaines. On comprend bien
que les phases de vérification et de certification sont au cœur du cycle de vie des logiciels
avioniques. A titre d’exemple, l’échec du premier vol d’Ariane 5 aurait certainement pu être
évité si le logiciel de contrôle de vol avait été vérifié plus efficacement [Lan96].
Plusieurs méthodes existent pour éliminer les risques de fautes. En fait, deux approches
duales sont nécessaires : les tests et les méthodes formelles. La première consiste à mettre
le logiciel dans des situations concrètes et à vérifier que la sortie correspond au résultat attendu
: c’est la technique des tests. Les tests « boîte noire » consistent à tester en ayant à disposition
uniquement les spécifications des modules à différentes échelles (par exemple : logiciel,
module, classe, méthode). Au contraire, les tests dits « boîte blanche » sont écrits en
ayant à disposition l’implémentation. Cela permet par exemple de s’assurer que chaque chemin
d’exécution est emprunté. Cette manière de procéder est similaire à la preuve par neuf
enseignée aux enfants : il est possible de prouver l’erreur, mais pas que le programme est
correct.8 CHAPITRE 1. INTRODUCTION
L’approche des méthodes formelles, au contraire, permet de s’assurer de l’absence d’erreurs
à l’exécution. Par exemple, l’analyse statique par interprétation abstraite permet d’étudier
les relations exposées entre les variables afin d’en déduire les ensembles de valeurs dans
lesquels elles évoluent. En s’assurant que ceux-ci sont « sûrs », on prouve l’absence d’erreurs
de manière automatisée.
L’interprétation abstraite repose sur l’idée suivante : au lieu de considérer que les variables
possèdent une valeur, on utilise un domaine abstrait qui permet de voir les variables
comme possédant un ensemble de valeurs possibles.
On dit que l’approche est sound si l’abstraction d’un ensemble de valeurs est un surensemble
de l’ensemble concret. Autrement dit, on réalise une surapproximation.
La zone « sûre » (correspondant aux exécutions sans erreurs) a une forme souvent assez
simple compte tenu des erreurs considérées : c’est un produit d’ensembles simples, comme
des intervalles. L’ensemble des comportements réels du programme est au contraire d’une
forme plus complexe et non calculable.
En calculant une approximation de ce dernier, de forme plus simple, on peut tester plus
facilement que les comportements sont dans la zone sûre : le fait que l’analyse soit sound,
c’est-à-dire que l’approximation ne manque aucun comportement, permet de prouver l’absence
d’erreurs.
La figure 1.1 résume cette approche : l’ensemble des valeurs dangereuses est représenté
par un ensemble hachuré, l’ensemble des valeurs sûres est en blanc, l’ensemble des comportements
réels du programme est noté par des points, et l’approximation en gris. Plusieurs cas
peuvent se produire. Dans la figure 1.1(a), on a prouvé à la compilation que le programme ne
pourra pas comporter d’erreurs à l’exécution. Dans la figure 1.1(b), l’approximation recouvre
les cas dangereux : on émet une alarme par manque de précision. Dans la figure 1.1(c) l’approximation
n’est pas sound (par construction, on évite ce cas). Enfin, dans la figure 1.1(d),
on émet une alarme à raison, car il existe des comportements erronés. Toute la difficulté est
donc de construire une surapproximation correcte mais conservant une précision suffisante.
Pour construire cette surapproximation, on peut employer divers outils. Par exemple, un
entier pourra être représenté par sa valeur minimale et sa valeur maximale (domaine abstrait
des intervalles), et un pointeur sur un tableau peut être représenté par un ensemble de variables
associé à un décalage (offset) par rapport au début de la zone mémoire (domaine des
pointeurs sur tableaux).
Le projet Penjili
Dans ce sens, des outils fondés sur l’interprétation abstraite ont été développés chez
EADS Innovation Works dans le cadre du projet Penjili [AH07].
Ces analyses statiques ne manipulent pas directement du code C, mais un langage intermédiaire
appelé NEWSPEAK [HL08]. Celui-ci est suffisamment expressif pour compiler la
plupart des programmes C, y compris de nombreuses extensions GNU utilisées dans le noyau
Linux (section 8.1), et des traducteurs automatiques depuis C et Ada existent (section 7.1).
Ensuite, ses instructions sont orthogonales et minimales : il existe en général une seule
manière de faire les choses. Par exemple, le flot de contrôle est restreint à la boucle infinie et
au saut en avant (« break » généralisé).
Enfin, lorsque certaines constructions sont ambigües, un choix est fait. Par exemple, l’évaluation
des arguments d’une fonction est faite dans un ordre précis, les tailles des types sont
indiquées à chaque déclaration de variable, etc.
Séparer le langage intermédiaire de la phase d’analyse permet de beaucoup simplifier
l’analyseur statique. D’une part, les constructions redondantes comme les différents types1.6. DE L’AVIONIQUE À L’INFORMATIQUE D’ENTREPRISE 9
(a) (b)
(c) (d)
FIGURE 1.1 : Surapproximation. L’ensemble des états erronés est hachuré. L’ensemble des
états effectifs du programme, noté par des points, est approximé par l’ensemble en gris.
de boucles ne sont traitées qu’une fois. D’autre part, lorsque le langage source est étendu
(en supportant une nouvelle extension de C par exemple), l’analyseur n’a pas besoin d’être
modifié.
Le langage NEWSPEAK, ainsi que les outils permettant de le manipuler, sont disponibles
sous license libre sur [☞3]. L’analyseur statique Penjili, reposant sur ces outils, a été utilisé
pour analyser des logiciels embarqués critiques de plusieurs millions de lignes de code. Ce
dernier n’est pour le moment pas open-source. Tous ces outils sont écrits dans le langage
OCaml [LDG+10, CMP03].
1.6 De l’avionique à l’informatique d’entreprise
Vérifier la sûreté des logiciels avioniques est critique, mais cela présente l’avantage que
ceux-ci sont développés avec ces difficultés à l’esprit. Il est plus simple de construire un système
sécurisé en connaissant toutes les contraintes d’abord, plutôt que de vérifier a posteriori
qu’un système existant peut répondre à ces contraintes de sûreté.
Néanmoins cette manière de concevoir des logiciels est très coûteuse. Pour des composants
qui sont moins critiques, il peut donc être intéressant de considérer des logiciels ou
bibliothèques existants, en particulier dans le monde de l’open-source.
Ces logiciels sont plus difficiles à analyser, car ils sont écrits sans contraintes particulières.
Non seulement toutes les constructions du langage sont autorisées, même celles qui sont
difficiles à traiter (transtypage, allocation dynamique, récursion, accès au système de fichiers,
etc), mais aussi des extensions non standards peuvent être utilisées.10 CHAPITRE 1. INTRODUCTION
Programmes non autosuffisants La grande majorité des programmes ne se suffisent pas
à eux-mêmes. En effet, ils interagissent presque toujours avec leur environnement ou appellent
des fonctions de bibliothèque.
Cela veut dire qu’un fichier en cours d’analyse peut contenir des appels à des fonctions
inconnues. Non seulement on n’a pas accès à leur code source, mais en plus on ne connaît
pas a priori leur spécification. Une solution peut être de prévoir un traitement particulier
pour celles-ci (par exemple en leur attribuant un type prédéfini).
Certaines interagissent directement avec le système d’exploitation, comme les fonctions
d’ouverture ou d’écriture dans un fichier. D’autres modifient totalement le mode d’exécution
du programme. Par exemple, pthread_create(&t, NULL, f, NULL) lance l’exécution
de f(NULL) tout en continuant l’exécution de la fonction en cours dans un fil d’exécution
concurrent.
Extensions du langage Par exemple, la figure 1.2 démontre l’influence de l’attribut packed
(supporté par GCC) sur la compilation d’une structure. Sans celui-ci, les champs sont alignés
de manière à faciliter les accès à la mémoire, par exemple en faisant démarrer les adresses
de chaque champ sur un multiple de 4 octets (en gras). Cela nécessite d’introduire des octets
de padding (en gris) qui ne sont pas utilisés. La taille totale de cette structure est donc de 12
octets.
struct s {
char a;
int b;
short c;
};
a b c
struct s {
char a;
int b;
short c;
} __attribute__((packed));
a b c
FIGURE 1.2 : Utilisation de l’attribut non-standard packed
Au contraire, l’utilisation de packed supprime totalement le padding et permet de diminuer
alors la taille de la structure à 7 octets seulement. Puisque b et c ne sont pas alignés, leur
accès sera fait de manière moins efficace.
De manière générale, les compilateurs permettent de personaliser finement le code émis
grâce à des extensions. Elles changent parfois le mode d’exécution des programmes d’une
manière subtile et pas toujours bien spécifiée ni documentée.
1.7 Objectifs et contributions de la thèse
Le but de ce travail est de définir et d’implanter des analyses statiques « légères » sur le
langage C (c’est-à-dire plus simples que les analyses de valeurs par interprétation abstraite)
pour détecter les utilisations dangereuses de pointeurs utilisateur. Nous proposons d’étendre
NEWSPEAK pour analyser des propriétés de sécurité par typage sur du code non avionique. En
effet, les types permettent de modéliser l’environnement d’exécution d’un programme (ici,
les paramètres d’appels système) avec un grain assez grand, alors qu’être plus fin est difficile
et nécessite de modéliser l’environnement.1.8. PLAN DE LA THÈSE 11
Nos contributions sont les suivantes :
• Une première étape est de définir un sous-ensemble sûr du langage source. En effet, le
langage C permet des conversions non sûres entre données, ce qui limite l’intérêt du
typage. On définit alors un langage impératif avec un modèle mémoire de plus haut
niveau, interdisant ces constructions : SAFESPEAK. Celui-ci est un modèle inspiré du
langage NEWSPEAK, déjà utilisé pour d’autres analyses statiques.
• Sur ce langage on définit une sémantique opérationnelle, qui permet de raisonner sur
les exécutions des programmes. On profite du caractère structuré des états mémoire
pour exprimer cette sémantique en terme de lentilles bidirectionnelles, permettant de
décrire la modification en profondeur de la mémoire.
• Au cœur de notre travail se trouve un système de types sûrs pour SAFESPEAK, ainsi que
deux extensions. La première permet d’illustrer l’approche typage en détectant les entiers
utilisés comme ensembles de bits, et la seconde permet de résoudre notre problème
de base, qui est la vérification des accès aux pointeurs utilisateur (présentés dans
la section 1.2).
• Notre formalisation est accompagnée d’un prototype, basé sur NEWSPEAK. Cela permet
d’appliquer les règles de typage précédemment définies sur des programmes écrits en
C, grâce aux outils existants développés par EADS. En particulier, cela permet d’analyser
des parties du noyau Linux. Ce prototype est disponible sous une license libre.
1.8 Plan de la thèse
Cette thèse est organisée en trois parties. La première décrit le contexte de ces travaux,
ainsi que les solutions existantes. La deuxième expose notre solution, SAFESPEAK, d’un point
de vue théorique. La troisième rend compte de la démarche expérimentale : comment la solution
a été implantée et en quoi elle est applicable en pratique.
Dans la partie I, on présente tout d’abord le fonctionnement général d’un système d’exploitation.
On y introduit aussi les problèmes de manipulation de pointeurs contrôlés par
l’utilisateur. Ceux-ci sont centraux puisqu’on désire les restreindre. On fait ensuite un tour
d’horizon des techniques existantes permettant de traiter ce problème par analyse statique
de code source.
Dans la partie II, on décrit notre solution : le langage SAFESPEAK. Sa syntaxe y est d’abord
décrite, puis sa sémantique ainsi qu’un système de types statiques. À ce niveau on a un bon
support pour décrire des analyses statiques sur un langage impératif. On propose alors deux
extensions du système de types. La première consiste à bien typer les entiers utilisés comme
bitmasks. La seconde capture les problèmes d’adressage mémoire présents dans les systèmes
d’exploitation, décrits dans la section 1.2. Pour ce faire, on ajoute des pointeurs contrôlés par
l’utilisateur à la sémantique et au système de types. À chaque étape, c’est-à-dire avant et après
ces ajouts, on établit une propriété de sûreté de typage reliant la sémantique d’exécution aux
types statiques.
Dans la partie III, on documente la démarche expérimentale associée à ces travaux. L’implantation
du système de types sur le langage NEWSPEAK est d’abord décrite, reposant sur
l’algorithme W de Damas et Milner. La manière de compiler depuis du code C est également
présentée. Ensuite, on applique cette implantation à deux cas d’étude concrets dans le noyau12 CHAPITRE 1. INTRODUCTION
Linux. L’un est un bug ayant touché un pilote de carte graphique, et l’autre un défaut dans
l’implantation de la fonction ptrace. Dans chaque cas, un pointeur dont la valeur est contrô-
lée par l’utilisateur crée un problème de sécurité, car un utilisateur malveillant peut lire ou
écrire dans l’espace mémoire réservé au noyau. En lançant notre prototype, l’analyse de la
version non corrigée lève une erreur alors que, dans la version corrigée, un type correct est
inféré. On montre ainsi que le système de types capture précisément ce genre d’erreur de
programmation.
On conclut enfin en décrivant les possibilités d’extensions autant sur le point théorique
qu’expérimental.Première partie
Méthodes formelles pour la sécurité
Après avoir décrit le contexte général de ces travaux, nous décrivons leurs enjeux.
Le chapitre 2 explore plus en détail le fonctionnement d’un système d’exploitation,
y compris la séparation du code en plusieurs niveaux de privilèges. L’architecture
Intel 32 bits est prise comme support. En particulier, le mécanisme des appels
système est décrit et on montre qu’une implantation naïve de la communication
entre espaces utilisateur et noyau casse toute isolation.
Le chapitre 3 consiste en un tour d’horizon des techniques existantes en analyses
de programmes. Ces analyses se centrent autour des problèmes liés à la vé-
rification de code système ou embarqué, y compris le problème de manipulation
mémoire évoqué dans le chapitre 2.
On conclut en introduisant notre solution : SAFESPEAK, un langage permettant
de typer des programmes impératifs, plus précisément en ajoutant des types pointeurs
abstraits.
13C H A P I T R E
2
SYSTÈMES D’EXPLOITATION
Le système d’exploitation est le programme qui permet à un système informatique d’exé-
cuter d’autres programmes. Son rôle est donc capital et ses responsabilités, multiples. Dans
ce chapitre, nous allons voir à quoi il sert, et comment il peut être implanté. Pour ce faire,
nous présentons ici de quoi est constitué un système Intel 32 bits et ce dont on se sert pour y
implanter un système d’exploitation.
2.1 Architecture physique
Un système informatique est principalement constitué d’un processeur (ou CPU pour
Central Processing Unit), de mémoire principale (ou RAM pour Random Access Memory) et
de divers périphériques.
Le processeur est constitué de plusieurs registres internes qui permettent d’encoder l’état
dans lequel il se trouve : quelle est l’instruction courante (registre EIP), quelle est la hauteur
de la pile système (registre ESP), etc. Son fonctionnement peut être vu de la manière la plus
simple qui soit comme la suite d’opérations :
• charger depuis la mémoire la prochaine instruction ;
• (optionnel) charger depuis la mémoire les données référencées par l’instruction ;
• effectuer l’instruction ;
• (optionnel) stocker en la mémoire les données modifiées ;
• continuer avec l’instruction suivante.
Les instructions sont constituées d’un opcode (mnémonique indiquant quelle opération
faire) et d’un ensemble d’opérandes. La signification des opérandes dépend de l’opcode,
mais en général ils permettent de désigner les sources et la destination (on emploiera ici
la syntaxe AT&T, celle que comprend l’assembleur GNU). Les opérandes peuvent avoir plusieurs
formes : une valeur immédiate ($4), un nom de registre (%eax) ou une référence à la
mémoire (directement : addr ou indirectement : (%ecx) 1 ). On décrit les opcodes les plus
utilisés, permettant de compiler un cœur de langage impératif :
• mov src, dst copie le contenu de src dans dst ;
• add src, dst calcule la somme des contenus de src et dst et place ce résultat dans
dst ;
1. Cela consiste à interpréter le contenu du regitre ECX comme une adresse mémoire.
1516 CHAPITRE 2. SYSTÈMES D’EXPLOITATION
• push src place src sur la pile, c’est-à-dire que cette instruction enlève au pointeur de
pile ESP la taille de src, puis place src à l’adresse mémoire de la nouvelle valeur ESP ;
• pop src réalise l’opération inverse : elle charge le contenu de la mémoire à l’adresse
ESP dans src puis incrémente ESP de la taille correspondante ;
• jmp addr saute à l’adresse addr : c’est l’équivalent de mov addr, %eip ;
• call addr sert aux appels de fonction : cela revient à push %eip puis jmp addr ;
• ret sert à revenir d’une fonction : c’est l’équivalent de pop %eip.
Certaines de ces instructions font référence à la pile par le biais du registre ESP. Cette
zone mémoire n’est pas gérée de manière particulière. Elle permet de gérer la pile des appels
de fonction en cours grâce à la manière dont jmp et ret fonctionnent. Elle sert aussi à stocker
les variables locales des fonctions.
À l’aide de ces quelques instructions on peut implanter des algorithmes impératifs. Mais
pour faire quelque chose de visible, comme afficher à l’écran ou envoyer un paquet sur le
réseau, cela ne suffit pas : il faut parler au reste du matériel.
Pour ceci, il y a deux techniques principales. D’une part, certains périphériques sont
dits memory-mapped : ils sont associés à un espace mémoire particulier, qui ne permet pas
de stocker des informations mais de lire ou d’écrire des données dans le périphérique. Par
exemple, écrire à l’adresse 0xB8000 permet d’écrire des caractères à l’écran. L’autre système
principal est l’utilisation des ports d’entrée/sortie. Cela correspond à des instructions spé-
ciales in %ax, port et out port, %ax où port est un numéro qui correspond à un périphérique
particulier. Par exemple, en écrivant dans le port 0x60, on peut contrôler l’état des
indicateurs lumineux du clavier PS/2.
2.2 Tâches et niveaux de privilèges
Alternance des tâches
Sans mécanisme particulier, le processeur exécuterait uniquement une suite d’instructions
à la fois. Pour lui permettre d’exécuter plusieurs tâches, un système de partage du temps
existe.
À des intervalles de temps réguliers, le système est programmé pour recevoir une interruption.
C’est une condition exceptionnelle (au même titre qu’une division par zéro) qui fait
sauter automatiquement le processeur dans une routine de traitement d’interruption. À cet
endroit le code peut sauvegarder les registres et restaurer un autre ensemble de registres, ce
qui permet d’exécuter plusieurs tâches de manière entrelacée. Si l’alternance est assez rapide,
cela peut donner l’illusion que les programmes s’exécutent en même temps. Comme
l’interruption peut survenir à tout moment, on parle de multitâche préemptif.
En plus de cet ordonnancement de processus, l’architecture Intel permet d’affecter des
niveaux de privilège à ces tâches, en restreignant le type d’instructions exécutables, ou en
donnant un accès limité à la mémoire aux tâches de niveaux moins élevés.
Le matériel permet 4 niveaux de privilèges (nommés aussi rings) : le ring 0 est le plus
privilégié, le ring 3, le moins privilégié. Dans l’exemple précédent, on pourrait isoler l’ordonnanceur
de processus en le faisant s’exécuter en ring 0 alors que les autres tâches seraient en
ring 3.2.3. APPELS SYSTÈME 17
Mémoire virtuelle
À partir du moment où plusieurs processus s’exécutent de manière concurrente, un problème
d’isolation se pose : si un processus peut lire dans la mémoire d’un autre, des informations
peuvent fuiter ; et s’il peut y écrire, il peut en détourner l’exécution.
Le mécanisme de mémoire virtuelle permet de donner à deux tâches une vue différente
de la mémoire : c’est-à-dire que vue de tâches différentes, une adresse contiendra une valeur
différente (figure 2.1).
Processus 1 Mémoire Processus 2
FIGURE 2.1 : Mécanisme de mémoire virtuelle.
Ce mécanisme est contrôlé par la valeur du registre CR3 : les 10 premiers bits d’une
adresse virtuelle sont un index dans le répertoire de pages qui commence à l’adresse contenue
dans CR3. À cet index, se trouve l’adresse d’une table de pages. Les 10 bits suivants de
l’adresse sont un index dans cette page, donnant l’adresse d’une page de 4 kio (figure 2.2).
Plus de détails sur l’utilisation de ce mécanisme seront donnés dans la section 8.2.
31 2122 1112 0
Répertoire
de pages
Table de
pages
Page de
4 kio CR3
FIGURE 2.2 : Implantation de la mémoire virtuelle
2.3 Appels système
Avec une telle isolation, tout le code qui est exécuté en ring 3 a une expressivité limitée.
Il ne peut pas contenir d’instructions privilégiées comme in ou out, ni faire référence à des
périphériques mappés en mémoire. C’est en effet au noyau d’accéder au matériel, et pas au
code utilisateur.
Il est donc nécessaire d’appeler une routine du noyau depuis le code utilisateur. C’est le
but des appels système. Cela consiste à coupler une fonction du ring 3 à une fonction du
ring 0 : en appelant la fonction non privilégiée, le flot d’exécution se retrouve dans le noyau
avec les bons privilèges.18 CHAPITRE 2. SYSTÈMES D’EXPLOITATION
Bien sûr, il n’est pas possible de faire directement un call puisque cela consisterait à faire
un saut vers une zone plus privilégiée. Il y a plusieurs manières d’implanter ce mécanisme.
Nous décrivons ici la technique historique à l’aide d’interruptions.
Le processeur peut répondre à des interruptions, qui sont des événements extérieurs.
Cela permet d’écrire du code asynchrone. Par exemple, une fois qu’un long transfert mémoire
est terminé, une interruption est reçue. D’autres interruptions dites logicielles peuvent arriver
lorsqu’une erreur se produit. Par exemple, diviser par zéro provoque l’interruption 0, et
tenter d’exécuter une instruction privilégiée provoque l’interruption 14. On peut aussi provoquer
manuellement une interruption par une instruction int dédiée.
Une table globale définit, pour chaque numéro d’interruption, quelle est la routine à appeler
pour la traiter, avec quel niveau de privilège, ainsi que le niveau de privilège requis pour
pouvoir déclencher celle-ci avec l’instruction int.
Il est donc possible de créer une interruption purement logicielle (on utilise en général
le numéro 128, soit 0x80), déclenchable en ring 3 et traitée en ring 0. Les registres sont pré-
servés, donc on peut les utiliser pour passer un numéro d’appel système (par exemple 3 pour
read() et 5 pour open()) et leurs arguments.
2.4 Le Confused Deputy Problem
On a vu que les appels système permettent aux programmes utilisateur d’accéder aux
services du noyau. Ils forment donc une interface particulièrement sensible aux problèmes
de sécurité.
Comme pour toutes les interfaces, on peut être plus ou moins fin. D’un côté, une interface
pas assez fine serait trop restrictive et ne permettrait pas d’implanter tout type de logiciel. De
l’autre, une interface trop laxiste (« écrire dans tel registre matériel ») empêche toute isolation.
Il faut donc trouver la bonne granularité.
Nous allons présenter ici une difficulté liée à la manipulation de mémoire au sein de certains
types d’appels système.
Il y a deux grands types d’appels système. D’une part on trouve ceux qui renvoient un
simple entier, comme getpid qui renvoie le numéro du processus appelant.
pid_t pid = getpid();
printf("%d\n", pid);
Ici, pas de difficulté particulière : la communication entre le ring 0 et le ring 3 est faite
uniquement à travers les registres, comme décrit dans la section 8.2.
Mais la plupart des appels système communiquent de l’information de manière indirecte,
à travers un pointeur. L’appellant alloue une zone mémoire dans son espace d’adressage et
passe un pointeur à l’appel système. Ce mécanisme est utilisé par exemple par la fonction
gettimeofday (figure 2.3).
Considérons une implantation naïve de cet appel système qui écrirait directement
à l’adresse pointée. Si le pointeur fourni est dans l’espace d’adressage du processus, on est
dans le cas d’utilisation normal et l’écriture est donc possible.
Si l’utilisateur passe un pointeur dont la valeur correspond à la mémoire réservée au
noyau, que se passe-t-il ? Comme le déréférencement est fait dans le code du noyau, il est
également fait en ring 0, et va pouvoir être réalisé sans erreur : l’écriture se fait et potentiellement
une structure importante du noyau est écrasée.
Un utilisateur malveillant peut donc utiliser cet appel système pour écrire à n’importe
quelle adresse dans l’espace d’adressage du noyau. Ce problème vient du fait que l’appel2.4. LE CONFUSED DEPUTY PROBLEM 19
struct timeval tv;
struct timezone tz;
int z = gettimeofday(&tv, &tz);
if (z == 0) {
printf( "tv.tv_sec = %ld\ntv.tv_usec = %ld\n"
"tz.tz_minuteswest = %d\ntz.tz_dsttime = %d\n",
tv.tv_sec, tv.tv_usec,
tz.tz_minuteswest, tz.tz_dsttime
);
}
FIGURE 2.3 : Appel de gettimeofday
système utilise les privilèges du noyau au lieu de celui qui contrôle la valeur des paramètres
sensibles. Cela s’appelle le Confused Deputy Problem[Har88].
La bonne solution est de tester dynamiquement la valeur du pointeur : s’il pointe en espace
noyau, il faut indiquer une erreur plutôt que d’écrire. Sinon, il peut toujours y avoir une
erreur, mais au moins le noyau est protégé.
Dans le noyau, un ensemble de fonctions permet d’effectuer des copies sûres. La fonction
access_ok réalise le test décrit précédemment. Les fonctions copy_from_user et copy_to_
user réalisent une copie de la mémoire après avoir fait ce test. Ainsi, l’implantation correcte
de l’appel système gettimeofday fait appel à celle-ci (figure 2.4).
SYSCALL_DEFINE2(gettimeofday, struct timeval __user *, tv,
struct timezone __user *, tz)
{
if (likely(tv != NULL)) {
struct timeval ktv;
do_gettimeofday(&ktv);
if (copy_to_user(tv, &ktv, sizeof(ktv)))
return -EFAULT;
}
if (unlikely(tz != NULL)) {
if (copy_to_user(tz, &sys_tz, sizeof(sys_tz)))
return -EFAULT;
}
return 0;
}
FIGURE 2.4 : Implantation de l’appel système gettimeofday
Pour préserver la sécurité du noyau, il est donc nécessaire de vérifier la valeur de tous
les pointeurs dont la valeur est contrôlée par l’utilisateur. Cette conclusion est assez contraignante,
puisqu’il existe de nombreux endroits dans le noyau où des données proviennent
de l’utilisateur. Il est donc raisonnable de vouloir vérifier automatiquement et statiquement
l’absence de tels défauts.C H A P I T R E
3
ANALYSES STATIQUES EXISTANTES
Dans ce chapitre, nous présentons un tour d’horizon des techniques existantes permettant
d’analyser des programmes. En particulier, on s’intéresse à la propriété d’isolation dé-
crite dans le chapitre 2, mais on ne se limite pas à celle-ci : il est également intéressant de
considérer des analyses développées pour d’autres propriétés (comme par exemple s’assurer
de l’absence d’erreurs à l’exécution), celles-ci pouvant potentiellement s’adapter.
L’analyse statique de programmes est un sujet de recherche actif depuis l’apparition de
l’informatique en tant que science. On commence par en présenter une classification, puis
on montrera des exemples pertinents permettant d’analyser du code système ou embarqué.
3.1 Taxonomie
Techniques statiques et dynamiques L’analyse peut être faite au moment de la compilation
ou au moment de l’exécution. En général on peut obtenir des informations plus précises
de manière dynamique, mais cela ne prend en compte que les parties du programme qui seront
vraiment exécutées. Un autre problème des techniques dynamiques est qu’il est souvent
nécessaire d’instrumenter l’environnement d’exécution (ce qui — dans le cas où cela est possible
— peut se traduire par un impact en performances). L’approche statique, en revanche,
nécessite de construire à l’arrêt une carte mentale du programme, ce qui n’est pas toujours
possible dans certains langages.
Les techniques dynamiques sont néanmoins les plus répandues, puisqu’elles sont plus
simples à mettre en œuvre et permettent de trouver des erreurs pendant le processus de dé-
veloppement. De plus, on peut considérer qu’un programme avec une forte couverture par
les tests a de grandes chances d’être correct pour toutes les entrées. Par exemple, dans l’avionique
civile, le processus de développement demande d’être très rigoureux pour les tests
fonctionnels et structurels afin de détecter le code ou les branchements non atteints.
Mais pour s’assurer de la correction d’un programme, on ne peut pas s’appuyer uniquement
sur les tests — ou de manière générale sur des analyses dynamiques — car il est souvent
impossible d’étudier l’ensemble complet de tous les comportements possibles. Par exemple,
si un bug se présente lors d’une interaction entre deux composants qui n’a pas été testée, il
passera inaperçu durant la phase de tests unitaires. Pour cette raison, la plupart des analyses
présentées ici sont statiques.
Cohérence et complétude Le but d’une analyse statique est de catégoriser les programmes
selon s’ils satisfont ou non un ensemble de propriétés fixées à l’avance. Malheureusement,
2122 CHAPITRE 3. ANALYSES STATIQUES EXISTANTES
cela n’est que rarement possible, car l’ensemble des valeurs possibles lors de l’exécution d’un
programme quelconque n’est pas un ensemble calculable (théorème de Rice [Ric53]). Autrement
dit, il ne peut exister une procédure de décision prenant un programme et le déclarant
correct ou incorrect. Un résultat similaire est qu’on ne peut pas écrire une procédure qui dé-
termine si un programme arbitraire boucle indéfiniment ou pas (le problème de l’arrêt).
Il n’est donc pas possible d’écrire un analyseur statique parfait, détectant exactement les
problèmes. Toute technique statique va donc de se retrouver dans au moins un des cas suivants
:
• un programme valide (pour une propriété donnée) est rejeté : on parle de faux positif.
• un programme invalide n’est pas détecté : on parle de faux négatif.
En général, et dans notre cas, on préfère s’assurer que les programmes acceptés possèdent
la propriété recherchée, quitte à en rejeter certains. C’est l’approche que nous retiendrons.
Tolérer les faux négatifs n’est cependant pas toujours une mauvaise idée. Par exemple,
si le but est de trouver des constructions dangereuses dans les programmes, on peut signaler
certains cas qui empiriquement valent d’être vérifiés manuellement.
Par ailleurs la plupart des techniques ne concernent que les programmes qui terminent.
On étudie donc la correction, ou les propriétés des termes convergents. Prouver automatiquement
que l’exécution ne boucle pas est une propriété toute autre qui n’est pas ici considérée.
3.2 Méthodes syntaxiques
L’analyse la plus simple consiste à traiter un programme comme du texte, et à y rechercher
des motifs dangereux. Ainsi, utiliser des outils comme grep permet parfois de trouver
un grand nombre de vulnérabilités [Spe05].
On peut continuer cette approche en recherchant des motifs mais en étant sensible à la
syntaxe et au flot de contrôle du programme. Cette notion de semantic grep est présente dans
l’outil Coccinelle [BDH+09, PTS+11] : on peut définir des patches sémantiques pour détecter
ou modifier des constructions particulières.
Ces techniques sont utiles parce qu’elles permettent de plonger rapidement dans le code,
en identifiant par exemple des appels à des fonctions dangereuses. En revanche, cela n’est
possible que lorsque les propriétés que l’on recherche sont plutôt locales. Elles offrent également
peu de garantie puisqu’elles ne prennent pas en compte la sémantique d’exécution du
langage : il faudra en général vérifier manuellement la sortie de ces analyses.
3.3 Analyse de valeurs et interprétation abstraite
L’interprétation abstraite est une technique d’analyse générique qui permet de simuler
statiquement tous les comportements d’un programme [CC77, CC92]. Un exemple d’application
est de calculer les bornes de variation des variables pour s’assurer qu’aucun débordement
de tableau n’est possible [AH07].
L’idée est d’associer à chaque ensemble concret de valeurs une représentation abstraite.
Sur celle-ci, on peut définir des opérations indépendantes de la valeur exacte des données,
mais préservant l’abstraction (figure 3.1). Par exemple, les règles comme « − » × « − » = « + »
définissent le domaine abstrait des signes arithmétiques. Les domaines ont une structure
de treillis, c’est-à-dire qu’ils possèdent les notions d’ordre partiel et d’union de valeurs. En
calculant les extrêmes limites d’une variable, on obtient le domaine des intervalles.3.3. ANALYSE DE VALEURS ET INTERPRÉTATION ABSTRAITE 23
�
− 0 +
⊥
γ (−) = R−
γ (0) = {0}
γ (+) = R+
FIGURE 3.1 : Domaine des signes
De tels domaines ne capturent aucune relation entre variables. Ils sont dits non relationnels.
Lorsque plusieurs variables sont analysées en même temps, utiliser de tels domaines
consiste à considérer un produit cartésien d’ensembles abstraits (figure 3.2(a)).
Des domaines abstraits plus précis permettent de retenir celles-ci. Pour ce faire, il faut
modéliser l’ensemble des valeurs des variables comme un tout. Parmi les domaines relationnels
courants on peut citer : le domaine des polyèdres [CH78], permettant de retenir tous
les invariants affines entre variables (figure 3.2(b)) ; le domaine des zones [Min01a], permettant
de représenter des relations affines de la forme vi − v j ≤ c (figure 3.2(c)) ; ou encore le
domaine des octogones [Min01b] qui est un compromis entre les polyèdres et les zones. Il
permet de représenter les relations ±vi ± v j ≤ c (figure 3.2(d)).
(a) Domaine non relationnel (b) Domaine des polyèdres
(c) Domaine des zones (d) Domaine des octogones
FIGURE 3.2 : Quelques domaines abstraits
En plus des domaines numériques, il est nécessaire d’employer des domaines spécialisés
dans la modélisation de la mémoire. Cela est nécessaire pour pouvoir prendre en compte
les pointeurs. Par exemple, on peut représenter un pointeur par un ensemble de variables
possiblement pointées et une valeur abstraite représentant le décalage (offset) du pointeur
par rapport au début de la zone mémoire. Cette valeur peut elle-même être abstraite par un
domaine numérique.
Au delà des domaines eux-mêmes, l’analyse se fait sous forme d’un calcul de point fixe.24 CHAPITRE 3. ANALYSES STATIQUES EXISTANTES
La manière la plus simple est d’utiliser un algorithme de liste de travail, décrit par exemple
dans [SRH96]. Les raffinements en revanche sont nombreux.
Dès [CC77] il est remarqué que la terminaison de l’analyse n’est assurée que si le treillis
des valeurs abstraites est de hauteur finie, ou qu’un opérateur d’élargissement (widening) ∇
est employé. L’idée est qu’une fois qu’on a calculé quelques termes d’une suite croissante,
on peut réaliser une projection de celle-ci. Par exemple, dans le domaine des intervalles,
[0; 2] ∇ [0; 3] = [0;+∞[. On atteint alors un point fixe mais qui est plus grand que celui qu’on
aurait obtenu sans cette accélération : on perd en précision. Pour en gagner, on peut redescendre
sur le treillis des points fixes avec une suite d’itérations décroissantes [Gra92, GGTZ07].
En termes d’ingéniérie logicielle, implanter un analyseur statique est un défi en soi. En
plus des domaines abstraits, d’un itérateur, il faut traduire le code source à analyser dans
un langage intermédiaire, et traduire les résultats de l’analyse en un ensemble d’alarmes à
présenter à l’utilisateur.
Cette technique est très puissante : si un interprète abstrait sound (réalisant une surapproximation,
c’est-à-dire ne manquant aucun programme incorrect) analyse un programme
et ne renvoie pas d’erreur, alors on a prouvé que le programme est correct (par rapport aux
propriétés que vérifient les domaines abstraits). Cela a été appliqué avec succès avec les analyseurs
Astrée [Mau04, CCF+05, CCF+09] chez Airbus ou CGS [VB04] à la NASA par exemple.
Cependant, ces analyses sont difficiles à mettre en œuvre. Avec des domaines abstraits
classiques comme ceux présentés ci-dessus, les premières analyses peuvent remonter un
nombre prohibitif de fausses alarmes. Pour « aider » l’analyse, il faut soit annoter le code soit
développer des domaines abstraits ad hoc au programme à analyser.
Il existe également des analyseurs statiques combinant l’interprétation abstraite avec
d’autres techniques et qui ne sont pas sound, c’est-à-dire qu’ils peuvent manquer des comportements
erronés. Leur approche est plus d’aider le programmeur à détecter certains types
de bugs pendant le développement. On peut citer l’exemple de Coverity [BBC+10], qui publie
régulièrement des rapports de qualité sur certains logiciels open-source. Néanmoins, de part
leur aspect non sound, les analyses réalisées ne peuvent pas être assimilées à de la vérification
formelle en tant que telle.
Enfin, l’interprétation abstraite n’est pas la seule technique pour analyser finement les
valeurs d’un programme. Par exemple, le système Saturn [ABD+07], conçu pour analyser
du code système écrit en C, utilise des clauses logiques et un solveur SAT pour manipuler
des invariants sur la mémoire. En particulier il traite le problème des pointeurs utilisateur
en utilisant une analyse de forme « pointe-sur » [BA08]. Un autre exemple est le model checking
[CE81], qui consiste à explorer l’ensemble des états que peut atteindre un système. Ce
graphe est potentiellement infini ; donc il peut être impossible de l’explorer pour détecter
les cas d’erreur. Plusieurs techniques permettent de résoudre ce problème. Le bounded model
checking [BCC+03] explore uniquement les états atteints en moins de k étapes. Cela peut
permettre de trouver des cas d’erreur, mais pas de montrer que le système est correct (seulement
qu’il l’est pour les exécutions de moins de k étapes). Il est aussi possible de réduire le
nombre d’états de l’automate [Pel93]. Comme l’interprétation abstraite, ces analyses sont très
précises, au détriment d’un temps de calcul important à cause de l’explosion combinatoire.
3.4 Typage
Le typage, introduit dans la section 1.3, peut aussi être utilisé pour la vérification de programmes.
On peut le voir comme une manière de catégoriser les types de données manipulés
par la machine, mais également, à plus haut niveau, comme une manière d’articuler les différents
composants d’un programme. Mais on peut aussi programmer avec les types, c’est-3.4. TYPAGE 25
à-dire utiliser le compilateur (dans le cas statique) ou l’environnement d’exécution (dans le
cas dynamique) pour vérifier des propriétés écrites par le programmeur.
Systèmes ad hoc Les systèmes de types les plus simples expriment des contrats esssentiellement
liés à la sûreté d’exécution, pour ne pas utiliser des valeurs de types incompatibles
entre eux. Mais il est possible d’étendre le langage avec des annotations plus riches,
par exemple en vérifiant statiquement que des listes ne sont pas vides [KcS07] ou, dans le
domaine de la sécurité, d’empêcher des fuites d’information [LZ06].
Qualificateurs de types Dans le cas particulier des vulnérabilités liées à une mauvaise utilisation
de la mémoire, les développeurs du noyau Linux ont ajouté un système d’annotations
au code source. Un pointeur peut être décoré d’une annotation __kernel ou __user selon
s’il est sûr ou pas. Celles-ci sont ignorées par le compilateur, mais un outil d’analyse statique
ad-hoc nommé Sparse [☞6] peut être utilisé pour détecter les cas les plus simples d’erreurs. Il
demande aussi au programmeur d’ajouter de nombreuses annotations dans le programme.
Cette solution se rapproche de la solution décrite dans ce manuscrit. Ce système d’annotations
sur les types a été formalisé sous le nom de qualificateurs de types [FJKA06] : chaque
type peut être décoré d’un ensemble de qualificateurs (à la manière de const), et des règles
de typage permettent d’établir des propriétés sur le programme.
Plus précisément, les jugements de typage de la forme Γ � e : t sont remplacés par des
jugements de typage qualifiés Γ � e : t q. Les qualificateurs q permettent d’exprimer plusieurs
jugements. Par exemple, on peut étudier le fait qu’une variable soit constante ou pas,
que sa valeur soit connue à la compilation, ou encore qu’elle puisse être nulle ou pas. La spé-
cificité de ce système est que les qualificateurs sont ordonnés, du plus spécifique au moins
spécifique, et que l’on forme alors un treillis à partir de ces informations. Partant des deux
caractéristiques précédentes, on forme le treillis de la figure 3.3. Le qualificateur const dé-
signe les données dont la valeur ne change pas au cours de l’exécution ; dynamic celles qui
ne peuvent pas être connues à la compilation ; et nonzero celles qui ne peuvent jamais être
nulles. Le cube sur lequel se trouvent les qualificateurs correspond à une relation d’ordre,
du plus spécifique (en bas) au plus général (en haut). ø correspond à un ensemble vide de
qualificateurs.
dynamic
ø const dynamic dynamic nonzero
const nonzero const dynamic nonzero
const nonzero
FIGURE 3.3 : Treillis de qualificateurs
Cette relation d’ordre � entre qualificateurs induit une relation de sous-typage � entre
les types qualifiés : si q � q�
, alors t q � t q�
.
Ces analyses ont été implantées dans l’outil CQual. Ce système peut servir à inférer les annotations
const [FFA99], à l’analyse de souillure pour les chaînes de format [STFW01] (pouvant
poser des problèmes de sécurité [New00]) et à déterminer des propriétés dépendantes
du flot de contrôle, comme des invariants sur les verrous [FTA02], à rapprocher du concept26 CHAPITRE 3. ANALYSES STATIQUES EXISTANTES
de typestates [SY86]. Il a également été appliqué à la classe de vulnérabilités sur les pointeurs
utilisateur dont il est question ici [JW04].
Cette approche est assez proche de la nôtre : on donne un type différent aux pointeurs
selon leur provenance. Néanmoins cela est très différent. Une première différence est dans
le langage considéré. CQual s’applique sur un lambda-calcul à références, alors que, pour
étudier du code C, nous présentons un modèle mémoire avec pile explicite plus proche de
la machine. D’autre part, le système de types de CQual est fondamentalement modifié pour
prendre en compte ces opérations, alors que dans le nôtre il s’agit d’une simple extension qui
ne nécessite pas de modifier toutes les règles de typage. La conclusion de la partie II, page 95,
sera dédiée à une comparaison entre ces solutions.
Le système Flow Caml [Sim03] repose également sur cette approche, en ajoutant une étiquette
de sécurité à chaque type. Par exemple, les entiers sont typés ’a int où ’a est le
niveau de sécurité associé. Couplé à un système d’effets, cela permet de suivre la provenance
de chaque expression. Cette technique d’analyse de flot permet d’encoder de nombreuses
propriétés de sécurité [SM03].
Ces techniques de typage sont séduisantes parce qu’elles sont en général simples à mettre
en place : à l’aide d’un ensemble de règles, on attribue un type à chaque expression. Si le
typage se termine sans erreur, alors on est assuré de la correction du programme (par rapport
aux propriétés capturées par le système de types).
Le typage statique peut également être implanté de manière efficace. Même si l’inférence
peut, dans certains cas, atteindre une complexité exponentielle [Mai90] (voire être indécidable),
la plupart des systèmes de types peuvent être vérifiés en pratique dans un temps linéaire
en la taille du programme considéré [McA03].
3.5 Langages sûrs
Une autre approche est de concevoir un langage à la fois bas niveau et sûr, permettant
d’exprimer des programmes proches de la machine tout en interdisant les constructions dangereuses.
Le langage Cyclone [JMG+02] est conçu comme un C « sûr ». Afin d’apporter plus de
sûreté au modèle mémoire de C, des tests dynamiques sont ajoutés, par exemple aux endroits
où des conversions implicites peuvent poser problème. Le langage se distingue par le
fait qu’il possède plusieurs types de pointeurs : des pointeurs classiques (int *), des pointeurs
« jamais nuls » (int @ ; un test à l’exécution est alors inséré) et des « pointeurs lourds »
(int ? ; qui contiennent des informations sur la zone mémoire pointée). L’arithmétique des
pointeurs n’est autorisée que sur ces derniers, rendant impossibles les débordements de tableaux
(ceux-ci étant détectés au pire à l’exécution). Le problème des pointeurs fous 1 est
résolu en utilisant un système de régions [GMJ+02], inspiré des travaux de Jouvelot, Talpin et
Tofte [TJ92, TT94]. Cela permet d’interdire statiquement les constructions où l’on déréfé-
rence un pointeur faisant référence à une région de mémoire qui n’est plus allouée (par
exemple en évitant de retourner l’adresse d’une variable locale). Cette approche peut également
servir à suivre les provenances de données sensibles [BGH10].
Le langage Rust [☞5] développé par Mozilla prend une approche similaire en distinguant
plusieurs types de pointeurs pour gérer la mémoire de manière plus fine. Les managed poin-
1. Les pointeurs fous, encore appelés pointeurs fantômes ou dangling pointers, correspondent à une zone
mémoire invalide ou expirée. Il y a deux sources principales de pointeurs fous : les variables de type pointeur
non initialisées, et les pointeurs vers des objets dont la mémoire a été libérée. C’est par exemple ce qui arrive aux
adresses de variables locales une fois que la fonction dans laquelle elles ont été définies retourne.3.6. LOGIQUE DE HOARE 27
ters (notés @int) utilisent un ramasse-miettes pour libérer la mémoire allouée lorsqu’ils ne
sont plus accessibles. Les owning pointers (notés ~int) décrivent une relation 1 à 1 entre
deux objets, comme les std::unique_ptr de C++ : la mémoire est libérée lorsque le pointeur
l’est. Les borrowed pointers (notés &int) correspondent aux pointeurs obtenus en prenant
l’adresse d’un objet, ou d’un champ d’un objet. Une analyse statique faite lors de la compilation
s’assure que la durée de vie de ces pointeurs est plus courte que l’objet pointé, afin
d’éviter les pointeurs fous. Cette analyse est également fondée sur les régions. Une fonction
qui retourne l’adresse d’une variable locale sera donc rejetée par le compilateur. Enfin, le dernier
type est celui des raw pointers (notés *int), pour lesquels le langage n’apporte aucune
garantie (il faut d’ailleurs encapsuler chaque utilisation dans un bloc marqué explicitement
unsafe). Ils sont équivalents aux pointeurs de C.
Les systèmes de types de ces projets apportent dans le langage différents types de pointeurs.
Cela permet de manipuler finement la mémoire, à la manière des smart pointers de
C++. Ceux-ci sont des types de données abstraits permettant de déterminer quelle partie du
code est responsable de la libération de la mémoire associée au pointeur.
De cette approche on retient surtout l’analyse de régions de Rust qui permet de manipuler
de manière sûre les adresses des variables locales, et les pointeurs lourds de Cyclone, qui
apportent une sûreté à l’arithmétique de pointeurs, au prix d’un test dynamique.
Ces techniques sont utiles pour créer des nouveaux programmes sûrs, mais on ne peut
pas les appliquer pour étudier la correction de logiciels existants. Dans cette perspective, le
langage CCured [NCH+05] a pour but d’ajouter un système de types forts à C (y compris pour
des programmes existants). Dans les cas où il n’est pas possible de prouver que le programme
s’exécutera correctement, des vérifications à l’exécution sont ajoutées. Cependant, cela né-
cessite une instrumentation dynamique qui se paye en performances et interdit la certification,
car l’environnement d’exécution doit être inchangé. Le compilateur Fail-Safe C [Oiw09]
utilise une approche similaire permettant de garantir la sûreté d’exécution des programmes
C tout en respectant la totalité de la norme C89.
3.6 Logique de Hoare
Une technique pour vérifier statiquement des propriétés sur la sémantique d’un programme
a été formalisée par Robert Floyd [Flo67] et Tony Hoare [Hoa69].
Elle consiste à écrire les invariants qui sont maintenus à un point donné du programme.
Ces propositions sont écrites dans une logique L . Chaque instruction i est annotée d’une
pré-condition P et d’une post-condition Q, ce que l’on note {P} i {Q}. Cela signifie que, si P
est vérifiée et que l’exécution de i se termine, alors Q sera vérifiée.
En plus des règles de L , des règles d’inférence traduisent la sémantique du programme ;
par exemple la règle de composition est :
{P} i1 {Q} {Q} i2 {R}
{P} i1;i2 {R}
(HOARE-SEQ)
Les pré-conditions peuvent être renforcées et les post-conditions relâchées :
�L P� ⇒ P {P} i {Q} �L Q ⇒Q�
{P�
} i {Q�
}
(HOARE-CONSEQUENCE)28 CHAPITRE 3. ANALYSES STATIQUES EXISTANTES
Il est alors possible d’annoter le programme avec ses invariants formalisés de manière
explicite dans L . Ceux-ci seront vérifiés à la compilation lorsque c’est possible, sinon à l’exé-
cution.
La règle de conséquence permet de séparer les propriétés du programme lui-même :
plusieurs niveaux d’annotations sont possibles, du moins précis au plus précis. En fait, il
est même possible d’annoter chaque point de contrôle par l’ensemble d’annotations vide :
{T } i {T } est toujours vrai.
Augmenter graduellement les pré- et post-conditions est néanmoins assez difficile, puisqu’il
peut être nécessaire de modifier l’ensemble des conditions à la fois. Cette difficulté est
mentionnée dans [DRS03], où un système de programmation par contrats est utilisé pour
vérifier la correction de routines de manipulation de chaînes en C.
Ce type d’annotations a été implanté par exemple pour le langage Java dans le système
JML [LBR06] ou pour le langage C# dans Spec# [BLS05]. Il est aussi possible d’utiliser cette
technique pour annoter du code assembleur de bas niveau [MG07].
3.7 Assistants de preuve
Avec un système de types classique, le fait qu’un terme (au sens « expression » ou « instruction
») soit bien typé amène quelques propriétés sur son exécution, par exemple, le fait
que seulement un ensemble réduit d’erreurs puisse arriver (comme la division par zéro).
En enrichissant le langage des types, on peut augmenter l’expressivité du typage. Par
exemple, on peut former des types « entier pair », « vecteur de n entiers », ou encore « liste
triée d’entiers ».
Habituellement, les termes peuvent dépendre d’autres termes (par composition) ou de
types (par des annotations). Les types peuvent également dépendre d’autres types (par composition
de types : par exemple, un couple de a et de b a pour type a ∗b). Enrichir l’expressivité
du typage revient essentiellement à introduire des termes dans les types, comme n
dans l’exemple précédent du vecteur de n entiers. C’est pourquoi on parle de types dépendants.
Parmi les langages proposant ces types on peut citer Coq [The04], Agda [BDN09] ou
Isabelle [NPW02].
Dans un langage classique, la plupart des types sont habités, c’est-à-dire qu’il existe des
termes ayant ces types. En revanche, avec les types dépendants ce n’est pas toujours vrai : par
exemple « vecteur de −1 entiers » n’a pas d’habitants. Ainsi, pouvoir construire un terme d’un
type donné est une information en soi.
On peut voir ce phénomène sous un autre angle : les termes sont à leur type ce que les
preuves sont à leur théorème. Exhiber un terme ayant un type revient à donner la preuve d’un
théorème. À l’aide de cette correspondance, il est possible de voir un algorithme de vérification
de typage comme un algorithme de vérification de preuve automatique. Ces preuves ne
portent pas forcément sur des programmes. Par exemple, le théorème des 4 couleurs a été
prouvé en Coq [Gon07].
Cette technique est très complexe à mettre en œuvre, puisqu’il faut encoder toutes les
propriétés voulues dans un formalisme de très bas niveau (du niveau de la théorie des ensembles).
De plus, l’inférence de types devient rapidement indécidable.
Conclusion
Il existe de nombreuses techniques pour vérifier du code système ou embarqué. Il y a
divers choix à faire entre l’expressivité, l’intégration de tests dynamiques ou la facilité de mise3.7. ASSISTANTS DE PREUVE 29
en œuvre.
Pour résoudre le problème des pointeurs utilisateur dans les noyaux, le typage statique
est une solution performante et assez pragmatique, puisqu’elle peut s’appliquer à des programmes
existants. Son expressivité limitée nous empêche de reposer entièrement sur elle
pour garantir l’absence d’erreur dans les programmes systèmes (par exemple, le typage est
mal adapté pour détecter les divisions par zéro). C’est pourquoi nous approchons la sûreté
de la manière suivante :
• Tout d’abord, on utilise le typage pour manipuler les données de manière compatible :
les types des opérations et fonctions sont vérifiés à la compilation.
• Ensuite, les accès aux tableaux et aux pointeurs sont vérifiés dynamiquement. Dans
le cas où une erreur est déclenchée, l’exécution s’arrête plutôt que de corrompre la
mémoire. La pile est également nettoyée à chaque retour de fonction afin d’éviter les
pointeurs fous.
• Enfin, les pointeurs provenant de l’espace utilisateur sont repérés statiquement afin
que leur déréférencement se fasse au sein de fonctions sûres. Cela permet de préserver
l’isolation entre le noyau et l’espace utilisateur.CONCLUSION DE LA PARTIE I
Nous avons montré que l’écriture de noyaux de systèmes d’exploitation nécessite de manipuler
des données provenant d’une zone non sûre, l’espace utilisateur. Parmi ces données,
il arrive de récupérer des pointeurs qui servent à passer des données par référence à
l’appelant, dans certains appels système. Si on déréférence ces pointeurs sans vérifier qu’ils
pointent bien vers une zone mémoire également contrôlée par l’appelant, on risque de lire
ou d’écrire dans des zones mémoires réservées au noyau seul.
Nous proposons une technique de typage pour détecter ces cas dangereux. Elle est plus
adaptée qu’une analyse de valeurs, car le grain pour distinguer les pointeurs sensibles des
pointeurs sûrs n’a pas besoin d’être très fin.
Pour décrire ces analyses, on commence par définir un langage impératif bien typable
que nous appellerons SAFESPEAK. Celui-ci s’inspire du langage NEWSPEAK, qui est un langage
intermédiaire développé par EADS dans le but de vérifier la sûreté de programmes C
embarqués. À ce titre, il existe un compilateur qui est capable de traduire du code C vers
NEWSPEAK.
Définir la syntaxe et la sémantique de SAFESPEAK permet d’écrire et d’évaluer des programmes.
Mais cela reste trop permissif, car on ne rejette pas les programmes qui manipulent
les données de manière incohérente. On définit donc un système de types pour classifier les
expressions et fonctions selon la classe de valeurs que leur évaluation produit.
Une fois SAFESPEAK défini et étendu d’un système de types, nous lui ajoutons des constructions
permettant d’écrire du code noyau, et en particulier on lui ajoute des pointeurs utilisateur.
Il s’agit de pointeurs dont la valeur est contrôlée par un utilisateur interagissant via un
appel système. Ces pointeurs ont un type distinct des pointeurs habituels.
En résumé, le but de cette thèse est de définir un langage intermédiaire proche de C, mais
bien typé ; puis de définir une analyse de typage qui vérifie que les pointeurs utilisateur sont
manipulés sans causer de problèmes de sécurité.
31Deuxième partie
Un langage pour l’analyse de code
système : SAFESPEAK
Dans cette partie, nous allons présenter un langage impératif modélisant une
sous-classe « bien typable » du langage C. Le chapitre 4 décrit sa syntaxe, ainsi que
sa sémantique d’exécution. À ce point, de nombreux programmes acceptés peuvent
provoquer des erreurs à l’exécution.
Afin de rejeter ces programmes incorrects, on définit ensuite dans le chapitre 5
une sémantique statique s’appuyant sur un système de types simples. Des proprié-
tés de sûreté de typage sont ensuite établies, permettant de catégoriser l’ensemble
des erreurs à l’exécution possibles.
Le chapitre 6 commence par étendre notre langage avec une nouvelle classe
d’erreurs à l’exécution, modélisant les accès à la mémoire utilisateur catégorisés
comme dangereux dans le chapitre 2. Une extension au système de types du chapitre
5 est ensuite établie, et on prouve que les programmes ainsi typés ne peuvent
pas atteindre ces cas d’erreur.
Trois types d’erreurs à l’exécution sont possibles :
• les erreurs liées aux valeurs : lorsqu’on tente d’appliquer à une opération des
valeurs incompatibles (additionner un entier et une fonction par exemple).
L’accès à des variables qui n’existent pas rentre aussi dans cette catégorie.
• les erreurs mémoire, qui résultent d’un débordement de tableau, du déréfé-
rencement d’un pointeur invalide ou d’arithmétique de pointeur invalide.
• les erreurs de sécurité, qui consistent en le déréférencement d’un pointeur
dont la valeur est contrôlée par l’espace utilisateur. Celles-ci sont uniquement
possibles en contexte noyau.
L’introduction des types simples enlève la possibilité de rencontrer le premier
cas. Il reste en revanche toujours possible de rencontrer des erreurs mémoire ainsi
que des divisions par zéro. Éliminer ces erreurs dépasse le cadre de ce travail.
En présence d’extensions permettant de manipuler des pointeurs utilisateur,
une extension naïve du système de types ne suffit pas à empêcher la présence d’erreurs
de sécurité. Celles-ci sont évitées par l’ajout de règles de typage supplémentaires.
33C H A P I T R E
4
SYNTAXE ET SÉMANTIQUE D’ÉVALUATION
Dans ce chapitre, on décrit le support de notre travail : un langage impératif nommé SAFESPEAK,
sur lequel s’appuieront les analyses de typage des chapitres 5 et 6.
Le langage C [KR88] est un langage impératif, conçu pour être un « assembleur portable ».
Ses types de données et les opérations associées sont donc naturellement de très bas niveau.
Ses types de données sont établis pour représenter les mots mémoire manipulables par
les processeurs : essentiellement des entiers et flottants de plusieurs tailles. Les types composés
correspondent à des zones de mémoire contigües, homogènes (dans le cas des tableaux)
ou hétérogènes (dans le cas des structures).
Une des spécificités de C est qu’il expose au programmeur la notion de pointeur, c’est-
à-dire de variables qui représentent directement une adresse en mémoire. Les pointeurs
peuvent être typés (on garde une indication sur le type de l’objet stocké à cette adresse) ou
« non typés ». Dans ce dernier cas, ils ont en fait le type void *, qui est compatible avec n’importe
quel type pointeur.
Son système de types rudimentaire ne permet pas d’avoir beaucoup de garanties sur la
sûreté du programme. En effet, aucune vérification n’est effectuée en dehors de celles faites
par le programmeur.
Le but ici est de définir SAFESPEAK, un langage plus simple mais qui permettra de raisonner
sur une certaine classe de programmes C.
Tout d’abord, on commence par présenter les notations qui accompagneront le reste des
chapitres. Cela inclut la notion de lentille, qui est utilisée pour définir les accès profonds
à la mémoire. Cela permet de résoudre le problème de mettre à jour une sous-valeur (par
exemple un champ de structure) d’une variable. Les lentilles permettent de définir de manière
déclarative que, pour faire cette opération, il faut obtenir l’ancienne valeur de la variable,
puis calculer une nouvelle valeur en remplaçant une sous-valeur, avant de replacer
cette nouvelle valeur à sa place en mémoire. En pratique, on définira deux lentilles : une qui
relie un état mémoire à la valeur d’une variable, et une qui relie une valeur à une de ses sousvaleurs.
Avec cette technique, on peut définir en une seule fois les opérations de lecture et
d’écriture de sous-valeurs imbriquées.
Ensuite, on présente SAFESPEAK en soi, c’est-à-dire sa syntaxe, ainsi que ses caractéristiques
principales. En particulier, le modèle mémoire est détaillé, ainsi que les valeurs manipulées
par le langage.
Enfin, on décrit une sémantique opérationnelle pour ce langage. Cela permet de définir
précisément l’exécution d’un programme SAFESPEAK au niveau de la mémoire.
L’implantation de ces analyses est faite dans le chapitre 7. Puisque SAFESPEAK n’est qu’un
modèle, il s’agira d’adapter ces règles de typage sur NEWSPEAK, qui possède un modèle mé-
3536 CHAPITRE 4. SYNTAXE ET SÉMANTIQUE D’ÉVALUATION
moire plus bas niveau.
4.1 Notations
Inférence
La sémantique opérationnelle consiste en la définition d’une relation de transition · → ·
entre états de l’interprète 1.
Cette relation est définie inductivement sur la syntaxe du programme. Plutôt que de pré-
senter l’induction explicitement, elle est représentée par des jugements logiques et des règles
d’inférence, de la forme :
P1 ... Pn
C
(NOM)
Les Pi sont les prémisses, et C la conclusion. Cette règle s’interprète de la manière suivante
: si les Pi sont vraies, alors C est vraie.
Certaines règles n’ont pas de prémisse ; ce sont des axiomes :
A
(AX)
Compte-tenu de la structure des règles, la dérivation d’une preuve (l’ordre dans lequel les
règles sont appliquées) pourra donc être vue sous la forme d’un arbre où les axiomes sont les
feuilles, en haut, et la conclusion est la racine, en bas.
A1
(R3)
A2
(R4)
B1
(R2)
A3
(R6)
B2
(R5)
C
(R1)
Listes
X ∗ est l’ensemble des suites finies de X, indexées à partir de 1. Si u ∈ X ∗, on note |u| le
nombre d’éléments de u (le cardinal de son domaine de définition). Pour i ∈ [1;|u|], on note
ui = u(i) le i-ème élément de la suite.
On peut aussi voir les suites comme des listes : on note [ ] la suite vide, telle que |[ ]| = 0.
On définit en outre la construction de suite de la manière suivante : si x ∈ X et u ∈ X ∗, la liste
x :: u ∈ X ∗ est la liste v telle que :
v1 = x
∀i ∈ [1;|u|], vi+1 = ui
Cela signifie que la tête de liste (x dans la liste x :: u) est toujours accessible à l’indice 1.
1. Dans le chapitre 5, la relation de typage · � · : · sera définie par la même technique.4.1. NOTATIONS 37
Lentilles
Dans la définition de la sémantique de SAFESPEAK, on utilise des lentilles bidirectionnelles.
Cette notion n’est pas propre à la sémantique des programmes. Il s’agit d’une technique
permettant de relier la modification d’un objet à la modification d’un de ses souscomposants.
Cela a plusieurs applications possibles. En programmation fonctionnelle pure
(sans mutation), on ne peut pas mettre à jour partiellement les valeurs composées comme
des enregistrements (records). Pour simuler cette opération, on a en général une opération
qui permet de définir un nouvel enregistrement dans lequel seul un champ a été mis à jour.
C’est ce qui se passe avec le langage Haskell [OGS08] : r { x = 5 } représente une valeur
enregistrement égale à r sur tous les champs, sauf pour le champ x où elle vaut 5. Utiliser
des lentilles revient à ajouter dans le langage la notion de champ en tant que valeur de première
classe. Elles ont l’avantage de pouvoir se composer, c’est-à-dire que, si on a un champ
nommé x qui contient un champ nommé y, alors on peut modifier le champ du champ automatiquement.
Dans ce cadre, les lentilles ont été popularisées par Van Laarhoven [vL11]. Puisque cela
sert à manipuler des données arborescentes, on peut aussi appliquer cet outil aux systèmes
de bases de données ou aux documents structurés comme par exemple en XML [FGM+07].
Dans notre cas, cela permettra par exemple de modifier un élément d’un tableau qui est
un champ de structure de la variable nommée x dans le 3e cadre de pile.
Définition 4.1 (Lentille). Étant donnés deux ensembles R et A, une lentille L ∈ LENSR,A (ou
accesseur) est un moyen d’accéder en lecture ou en écriture à une sous-valeur appartenant à
A au sein d’une valeur appartenant à R (pour record). Elle est constituée des opérations suivantes
:
• une fonction de lecture getL : R → A
• une fonction de mise à jour putL : (A ×R) → R
telles que pour tour a ∈ A,a� ∈ A, r ∈ R :
putL (getL (r ), r ) = r (GETPUT)
getL (putL (a, r )) = a (PUTGET)
putL (a�
,putL (a, r )) = putL (a�
, r ) (PUTPUT)
On note L = 〈getL |putL 〉.
GETPUT signifie que, si on lit une valeur puis qu’on la réécrit, l’objet n’est pas modifié ;
PUTGET décrit l’opération inverse : si on écrit une valeur dans le champ, c’est la valeur qui
sera lue ; enfin, PUTPUT évoque le fait que chaque écriture est totale : quand deux écritures se
suivent, seule la seconde compte.
Une illustration se trouve dans la figure 4.1.
Exemple 4.1 (Lentilles de tête et de queue de liste). Soit E un ensemble. On rappelle que E ∗
désigne l’ensemble des listes d’éléments de E.
On définit les fonctions suivantes. Notons qu’elles ne sont pas définies sur la liste vide [ ],
qui pourra être traitée comme un cas d’erreur.
getT (t :: q) = t putT (t
�
,t :: q) = t� :: q
getQ(t :: q) = q putQ(q�
,t :: q) = t :: q�38 CHAPITRE 4. SYNTAXE ET SÉMANTIQUE D’ÉVALUATION
getL ( ) =
putL ( , ) =
FIGURE 4.1 : Fonctionnement d’une lentille
Alors T = 〈getT |putT 〉 ∈ LENSE∗,E et Q = 〈getQ|putQ〉 ∈ LENSE∗,E∗ .
On a par exemple :
getT (1 :: 6 :: 1 :: 8 :: [ ]) = 1 putQ(4 :: 2 :: [ ], 3 :: 6 :: 1 :: 5 :: [ ]) = 3 :: 4 :: 2 :: [ ]
Définition 4.2 (Lentille indexée). Les objets de certains ensembles R sont composés de plusieurs
sous-objets accessibles à travers un indice i ∈ I. Une lentille indexée est une fonction Δ
qui associe à un indice i une lentille entre R et un de ses champs Ai :
∀i ∈ I,∃Ai ,Δ(i) ∈ LENSR,Ai
On note alors :
r [i]Δ
def
== getΔ(i)(r )
r [i ← a]Δ
def
== putΔ(i)(a, r )
Un exemple est illustré dans la figure 4.2.
getΔ(b)(
a b
c d
) =
getΔ(c)(
a b
c d
) =
putΔ(b)( ,
a b
c d
) =
a b
c d
putΔ(c)( ,
a b
c d
) =
a b
c d
FIGURE 4.2 : Fonctionnement d’une lentille indexée4.1. NOTATIONS 39
Exemple 4.2 (Lentille « ne élément d’un tuple »). Soient n ∈ N, et n ensembles E1,...,En.
Pour tout i ∈ [1;n], on définit :
gi((x1,...,xn)) = xi
pi(y, (x1,...,xn)) = (x1,...,xi−1, y,xi+1,...,xn)
Définissons T (i) = 〈gi |pi〉. Alors T (i) ∈ LENS(E1×...×En),Ei .
Donc T est une lentille indexée, et on a par exemple :
(3, 1, 4, 1, 5)[2]T = getT (2)((3, 1, 4, 1, 5))
= 1
(9, 2, 6, 5, 3)[3 ← 1]T = putT (3)(1, (9, 2, 6, 5, 3))
= (9, 2, 1, 5, 3)
La notation 3 ← 1 peut surprendre, mais elle est à interpréter comme « en remplaçant
l’élément d’indice 3 par 1 ».
Définition 4.3 (Composition de lentilles). Soient L1 ∈ LENSA,B et L2 ∈ LENSB,C .
La composition de L1 et L2 est la lentille L ∈ LENSA,C définie de la manière suivante :
getL (r ) = getL2
(getL1
r )
putL (a, r ) = putL1 (putL2 (a, getL1
r ), r )
On notera alors L = L1≫L2 (« L1 flèche L2 »).
getL1
putL2
putL1
getL1
getL2
FIGURE 4.3 : Composition de lentilles
Cette définition est illustrée dans la figure 4.3. Une preuve que la composition est une
lentille est donnée en annexe D.1.40 CHAPITRE 4. SYNTAXE ET SÉMANTIQUE D’ÉVALUATION
Constantes c ::= n Entier
| d Flottant
| NULL Pointeur nul
| ( ) Valeur unité
Expressions e ::= c Constante
| � e Opération unaire
| e � e Opération binaire
| l v Accès mémoire
| l v ← e Affectation
| &l v Pointeur
| f Fonction
| e(e1,...,en) Appel de fonction
| {l1 : e1;...;ln : en} Structure
| [e1;...;en] Tableau
Valeurs
gauches
l v ::= x Variable
| l v.lS Accès à un champ
| l v[e] Accès à un élément
| ∗e Déréférencement
Fonctions f ::= fun(x1,...,xn){i} Arguments, corps
FIGURE 4.4 : Syntaxe des expressions
4.2 Syntaxe
Les figures 4.4 et 4.5 présentent notre langage intermédiaire. Il contient la plupart des
fonctionnalités présentes dans les langages impératifs comme C.
Parmi les expressions, les constantes comportent les entiers et flottants, ainsi que le pointeur
NULL qui correspond à une valeur par défaut pour les pointeurs, et la valeur unité ( ) qui
pourra être retournée par les fonctions travaillant par effets de bord uniquement.
Les accès mémoire en lecture et écriture se font au travers de valeurs gauches (left values
ou lvalues) : comme en C, elles tiennent leur nom du fait que ce sont ces constructions qui
sont à gauche du signe d’affectation. En plus des variables, on obtient une valeur gauche en
accédant par nom à un champ ou par indice à un élément d’une valeur gauche, ou encore
en appliquant l’opérateur * de déréférencement à une expression. Pour assister le typage,
l’accès à un champ doit être décoré du type complet S, mais cette annotation est ignorée lors
de l’évaluation. Les valeurs gauches correspondent aussi à l’unité d’adressage : c’est-à-dire
que les pointeurs sont construits en prenant l’adresse d’une valeur gauche avec l’opérateur &.
Les fonctions sont des expressions comme les autres, contrairement à C où elles sont
forcément déclarées globalement. Cela veut dire qu’on peut affecter une fonction f à une va-4.3. MÉMOIRE ET VALEURS 41
Instructions i ::= PASS Instruction vide
| i;i Séquence
| e Expression
| DECL x = e IN{i} Déclaration de variable
| IF(e){i}ELSE{i} Alternative
| WHILE(e){i} Boucle
| RETURN(e) Retour de fonction
Phrases p ::= x = e Variable globale
| e Évaluation d’expression
Programme P ::= (p1,...,pn) Phrases
FIGURE 4.5 : Syntaxe des instructions
riable x et l’appeller avec x(a1,a2). Il est aussi possible de déclarer une fonction au sein d’une
fonction. Cependant cela ne respecte pas l’imbrication lexicale : dans la fonction interne il
n’est pas possible de faire référence à des variables locales de la fonction externe, seulement
à des variables globales. En mémoire les fonctions sont donc uniquement représentées par
leur code : il n’y a pas de fermetures.
Enfin, on trouve aussi des expressions permettant de construire des valeurs composées :
les structures et les tableaux.
Les instructions sont typiques de la programmation impérative. SAFESPEAK comporte
bien sûr l’instruction vide qui ne fait rien et la séquence qui chaîne deux instructions.
Une expression peut être évaluée dans un contexte d’instruction, pour ses effets de bord.
Remarquons que l’affectation est une expression, qui renvoie la valeur affectée. Cela permet
d’écrire x ← (y ← z), comme dans un programme C où on écrirait x = y = z.
Il est également possible de déclarer une variable locale avec DECL x = v IN{i}. x est alors
une nouvelle variable visible dans i avec pour valeur initiale v.
L’alternative et la conditionnelle sont classiques ; en revanche, on ne fournit qu’un seul
type de boucle et pas de saut (instruction goto).
Les opérateurs sont donnés dans la figure 4.6. Ils correspondent à ceux du langage C. La
différence principale est que les opérations sur les entiers, flottants et pointeurs sont annotées
avec le type de données sur lequel ils travaillent. Par exemple « + » désigne l’addition sur
les entiers et « +. » l’addition sur les flottants. Les opérations de test d’égalité, en revanche,
sont possibles pour les types numériques, les pointeurs, ainsi que les types composés de
types comparables.
4.3 Mémoire et valeurs
L’interprète que nous nous apprêtons à définir manipule des valeurs qui sont associées
aux variables du programme.42 CHAPITRE 4. SYNTAXE ET SÉMANTIQUE D’ÉVALUATION
Opérateurs
binaires
� ::= +,−,×,/,% Arithmétique entière
| +.,−.,×.,/. Arithmétique flottante
| +p,−p Arithmétique de pointeurs
| ≤,≥,<,> Comparaison sur les entiers
| ≤ .,≥ .,< .,> . Comparaison sur les flottants
| =,�= Tests d’égalité
| &,|,^ Opérateurs bit à bit
| &&,|| Opérateurs logiques
| �,� Décalages
Opérateurs
unaires
� ::= +,− Arithmétique entière
| +.,−. Arithmétique flottante
| ∼ Négation bit à bit
| ! Négation logique
FIGURE 4.6 : Syntaxe des opérateurs
La mémoire est constituée de variables (toutes mutables), qui contiennent des valeurs.
Ces variables sont organisées, d’une part, en un ensemble de variables globales et, d’autre
part, en une pile de contextes d’appel (qu’on appellera donc aussi cadres de pile, ou stack
frames en anglais). Cette structure empilée permet de représenter les différents contextes à
chaque appel de fonction : par exemple, si une fonction s’appelle récursivement, plusieurs
instances de ses variables locales sont présentes dans le programme. Le modèle mémoire
présenté ici ne permet pas l’allocation dynamique sur un tas. Cette limitation sera détaillée
dans le chapitre 9.
La structure de pile des variables locales permet de les organiser en niveaux indépendants
: à chaque appel de fonction, un nouveau cadre de pile est créé, comprenant ses paramètres
et ses variables locales. Au contraire, pour les variables globales, il n’y a pas de système
d’empilement, puisque ces variables sont accessibles depuis tout point du programme.
Pour identifier de manière non ambigüe une variable, on note simplement x la variable
globale nommée x, et (n,x) la variable locale nommée x dans le ne cadre de pile 2.
Les affectations peuvent avoir la forme x ← e où x est une variable et e est une expression,
mais pas seulement. En effet, à gauche de ← on trouve en général non pas une variable mais
une valeur gauche (par définition). Pour représenter quelle partie de la mémoire doit être accédée
par cette valeur gauche, on introduit la notion de chemin ϕ. Un chemin est une valeur
gauche évaluée : les cas sont similaires, sauf que tous les indices sont évalués. Par exemple,
ϕ = (5,x).p représente le champ « p » de la variable x dans le 5e cadre de pile. C’est à ce moment
qu’on évalue les déréférencements qui peuvent apparaître dans une valeur gauche.
Les valeurs, quant à elles, peuvent avoir les formes suivantes (résumées sur la figure 4.7) :
• c� : une constante. La notation circonflexe permet de distinguer les constructions syn-
2. Les paramètres de fonction sont traités comme des variables locales et se retrouvent dans le cadre correspondant.4.4.
INTERPRÈTE 43
taxique des constructions sémantiques. Par exemple, à la syntaxe 3 correspond la valeur
�3.
Les valeurs entières sont les entiers signés sur 32 bits, c’est-à-dire entre −231 à 231 − 1.
Mais ce choix est arbitraire : on aurait pu choisir des nombres à 64 bits, par exemple.
Les flottants sont les flottants IEEE 754 de 32 bits [oEE08].
Il n’y a pas de distinction entre procédures et fonctions ; toutes les fonctions doivent
renvoyer une valeur. Celles qui ne retournent pas de valeur « intéressante » renvoient
alors une valeur d’un type à un seul élément noté ( ), et donc le type sera noté UNIT.
Cette notation évoque un n-uplet à 0 composante.
• &� ϕ : une référence mémoire. Ce chemin correspond à un pointeur sur une valeur
gauche. Par exemple, l’expression &x s’évalue en &� ϕ = & (5, � x) si x désigne lexicalement
une variable dans le 5e cadre de pile.
• [v�1;...; vn] : un tableau. C’est une valeur composée qui contient un certain nombre
(connu à la compilation) de valeurs d’un même type, par exemple 100 entiers. On accède
à ces valeurs par un indice entier. C’est une erreur (Ωar r ay ) d’accéder à un tableau
en dehors de ses bornes, c’est-à-dire en dehors de [0;n − 1] pour un tableau à n élé-
ments. Pareillement, [
�·] permet de désigner les valeurs tableau. Par exemple, si x vaut 2
et y vaut 3, l’expression [x; y] s’évaluera en la valeur [2; 3] �
• {l1 : v�1;...;ln : vn} : une structure. C’est une valeur composée mais hétérogène. Les différents
éléments (appelés champs) sont désignés par leurs noms li (pour label). Dans
le programme, le nom de champ li est décoré de la définition complète de la structure
S. Celle-ci n’est pas utilisée dans l’évaluation et sera décrite au chapitre 5. Comme
précédemment, on note {
�·} pour dénoter les valeurs.
• f
� : une fonction. On garde en mémoire l’intégralité de la définition de la fonction (liste
de paramètres, de variables locales et corps). Même si les fonctions locales sont possibles,
il n’est pas possible d’accéder aux variables de la portée entourante depuis la
fonction intérieure (il n’y a pas de fermetures). Contrairement à C, les fonctions ne sont
pas des cas spéciaux. Par exemple, les fonctions globales sont simplement des variables
globales de type fonctionnel, et les « pointeurs sur fonction » de C sont remplacés par
des variables de type fonction.
• Ω : une erreur. Par exemple le résultat l’évaluation de 5/0 est Ωd i v .
Les erreurs peuvent être classifiées en deux grand groupes : d’une part, Ωf i eld , Ωvar et
Ωt yp sont des erreurs de typage dynamique, qui arrivent lorsqu’on accède dynamiquement
à des données qui n’existent pas ou qu’on manipule des types de données incompatibles.
D’autre part, Ωd i v , Ωar r ay et Ωp t r correspondent à des valeurs mal utilisées. Le but du système
de types du chapitre 5 sera d’éliminer complètement les erreurs du premier groupe.
4.4 Interprète
La figure 4.8 résume comment ces valeurs sont organisées. Une pile est une liste de cadres
de piles, et un cadre de pile est une liste de couples (nom, valeur). Un état mémoire m est un
couple (s, g ) où s est une pile et g un cadre de pile (qui représente les variables globales). On
note |m| = |s| la hauteur de la pile (en nombre de cadres).
Enfin, l’interprétation est définie comme une relation · → · entre états Ξ ; ces états sont
d’une des formes suivantes :44 CHAPITRE 4. SYNTAXE ET SÉMANTIQUE D’ÉVALUATION
Valeurs v ::= c� Constante
| &� ϕ Référence mémoire
| {l1 : v�1;...;ln : vn} Structure
| [v�1;...; vn] Tableau
| f
� Fonction
| Ω Erreur
Chemins ϕ ::= a Adresse
| ϕ�.l Accès à un champ
| ϕ[
�n] Accès à un élément
Adresses a ::= (n, x) Variable locale
| (x) Variable globale
Erreur Ω ::= Ωar r ay Débordement de tableau
| Ωp t r Erreur de pointeur
| Ωd i v Division par zéro
| Ωf i eld Erreur de champ
| Ωvar Variable inconnue
| Ωt yp Données incompatibles
FIGURE 4.7 : Valeurs
Pile s ::= [ ] Pile vide
| {x1 �→ v1;...;xn �→ vn} :: s Ajout d’un cadre
État mémoire m ::= (s, {x1 �→ v1;...;xn �→ vn}) Pile, globales
État d’interprète Ξ ::= 〈e,m〉 Expression, mémoire
| 〈i,m〉 Instruction, mémoire
| Ω Erreur
FIGURE 4.8 : Composantes d’un état mémoire4.5. OPÉRATIONS SUR LES VALEURS 45
• un couple 〈e,m〉 où e est une expression et m un état mémoire. m est l’état mémoire
sous lequel l’évaluation sera réalisée. Par exemple 〈3, ([ ], [x �→ 3])〉 → 〈�3, ([ ], [x �→ 3])〉
L’évaluation des expressions est détaillée dans la section 4.10.
• un couple 〈i,m〉 où i est une instruction et m un état mémoire. La réduction des instructions
est traitée dans la section 4.11. Par exemple, 〈(x ← 3; y ← x),m〉 → 〈y ←
x,m[x �→ �3]〉 → 〈PASS,m[x �→ �3][y �→ �3]〉.
Dans le cas général, utiliser des instructions pour représenter l’état des calculs ne suffit
pas ; il faut utiliser une continuation. C’est ce qui est fait par exemple dans la sémantique
de CMinor [AB07]. Ici, le flot de contrôle est plus simple et on peut se contenter
de retenir une simple instruction, ce qui simplifie la présentation.
• un couple 〈l v,m〉 où l v est une valeur gauche et m un état mémoire. L’évaluation des
valeurs gauches est décrite en section 4.9.
• une erreur Ω. La propagation des erreurs est détaillée dans la section 4.12.
L’évaluation des expressions, valeurs gauches et instructions se fait à petits pas. C’est-à-
dire qu’on simplifie d’étape en étape leur forme, jusqu’à arriver à un cas de base :
• pour les expressions, une valeur v ;
• pour les instructions, l’instruction PASS ou RETURN(v) où v est une valeur ;
• pour les valeurs gauches, un chemin ϕ.
On considère en fait la clôture transitive de cette relation. Cela revient à ajouter une règle :
Ξ1 → Ξ2 Ξ2 → Ξ3
Ξ1 → Ξ3
(TRANS)
4.5 Opérations sur les valeurs
Un certain nombre d’opérations est possible sur les valeurs (figure 4.6) :
• les opérations arithmétiques +, −, ×, / et % sur les entiers. L’opérateur % correspond
au modulo (reste de la division euclidienne). En cas de division par zéro, l’erreur Ωd i v
est levée.
• les versions « pointées » +., −., ×. et /. sur les flottants.
• les opérations d’arithmétique de pointeur +p et −p qui à un chemin mémoire et un
entier associent un chemin mémoire.
• les opérations d’égalité = et �=. L’égalité entre entiers ou entre flottants est immédiate.
Deux valeurs composées (tableaux ou structures) sont égales si elles ont la même forme
(même taille pour les tableaux, ou mêmes champs pour les structures) et que toutes
leurs sous-valeurs sont égales deux à deux. Deux références mémoire sont égales
lorsque les chemins qu’elles décrivent sont syntaxiquement égaux.
• les opérations de comparaison ≤,≥,<,> sont définies avec leur sémantique habituelle
sur les entiers et les flottants. Sur les références mémoires, elles sont définies dans le
cas où les deux opérandes sont de la forme ϕ[·] par : ϕ[n] � ϕ[m] def
== n � m. Dans
les autres cas, l’erreur Ωp t r est renvoyée. Notamment, il n’est pas possible de comparer
deux fonctions, deux tableaux ou deux structures.
• les opérateurs bit à bit sont définis sur les entiers. &, | et ^ représentent respectivement
la conjonction, la disjonction et la disjonction exclusive (XOR).46 CHAPITRE 4. SYNTAXE ET SÉMANTIQUE D’ÉVALUATION
• des versions logiques de la conjonction (&&) et de la disjonction (||) sont également
présentes. Leur sémantique est donnée par le tableau suivant :
n m n && m n || m
0 0 0 0
0 �= 0 0 1
�= 0 0 0 1
�= 0 �= 0 1 1
• des opérateurs de décalage à gauche (�) et à droite (�) sont présents. Eux aussi ne
s’appliquent qu’aux entiers.
• les opérateurs arithmétiques unaires +, −, +. et −. sont équivalents par définition à
l’opération binaire correspondante. Par exemple −4 def
== 0−4.
• ∼ inverse tous les bits de son opérande. ! est une version logique, c’est-à-dire que !0 = 1
et, si n �= 0, !n = 0.
Si ces opérateurs sémantiques reçoivent des données incompatibles (par exemple si on
tente d’ajouter une fonction et un entier), l’erreur spéciale Ωt yp est renvoyée.
4.6 Opérations sur les états mémoire
Définition 4.4 (Recherche de variable). La recherche de variable permet d’associer à une variable
x une adresse a.
Chaque fonction peut accéder aux variables locales de la fonction en cours, ainsi qu’aux
variables globales.
Remarque : le cadre de variables locales le plus récent a toujours l’indice 1.
Lookup((s, g ),x) =
(|s|,x) si |s| > 0 et (x �→ v) ∈ s1
x si (x �→ v) ∈ g
Ωvar sinon
En entrant dans une fonction, on rajoutera un cadre de pile qui contient les paramètres
de la fonction ainsi que ses variables locales. En retournant à l’appelant, il faudra supprimer
ce cadre de pile.
Définition 4.5 (Manipulations de pile). On définit l’empilement d’un cadre de pile c = ((x1 �→
v1),..., (xn �→ vn)) sur un état mémoire m = (s, g ) (figure 4.9(a)) :
Push((s, g ),c) = (c :: s, g )
On définit aussi l’extension du dernier cadre de pile, qui sert aux déclarations de variables
locales (figure 4.9(b)) :
Extend((c :: s, g ),x �→ v) = (((x �→ v :: c) :: s), g )
L’opération inverse de Extend(·,· �→ ·) sera simplement notée « − » : m − x, par exemple.
De même on définit le dépilement (figure 4.9(c)) :
Pop((c :: s, g )) = (s, g )4.6. OPÉRATIONS SUR LES ÉTATS MÉMOIRE 47
x �→ 0
Push(·, (x �→ 0))
(a) Empilement
x �→ 0 x �→ 0, y �→ 3
Extend(·, y �→ 3)
(b) Extension de cadre
...
Pop
(c) Dépilement
FIGURE 4.9 : Opérations de pile
Définition 4.6 (Hauteur d’une valeur). Une valeur peut contenir une référence vers une variable
de la pile. La hauteur d’une valeur est l’indice du plus haut cadre qu’elle référence, ou −1
sinon.
H (c�) = −1
H (f
�) = −1
H (&� ϕ) = HΦ(ϕ)
H ({l1 : v�1;...;ln : vn}) = max
i∈[1;n]
H (vi)
H ([v�1,..., vn]) = max
i∈[1;n]
H (vi)
où :
HΦ((x)) = −1
HΦ((n,x)) = n
HΦ(ϕ�.l) = HΦ(ϕ)
HΦ(ϕ[
�n]) = HΦ(ϕ)
Les opérations Extend et Pop ne sont définies que pour une pile non vide. Néanmoins
cela ne pose pas de problème, puisque, lors de l’exécution, la pile n’est vide que lors de l’évaluation
d’expressions dans les phrases de programme. À cet endroit, seules des expressions
peuvent apparaître, et leur évaluation ne manipule jamais la pile avec ces opérations.
On définit aussi une opération de nettoyage de pile, qui sera utile pour les retours de
fonction.
En effet, si une référence au dernier cadre est toujours présente après le retour d’une
fonction, cela peut casser le typage.
Par exemple, dans la figure 4.10, l’exécution de h( ) donne à p la valeur (1,x). Puis en
arrivant dans g , le déréférencement de p va modifier x qui va avoir la valeur 1. x, variable
flottante, contient donc un entier. Dans la ligne marquée (*), on réalise donc l’addition d’un
entier (contenu dans x malgré le type de la variable) et d’un flottant. Cette opération est bien
typée dans le programme mais provoquera une erreur Ωt yp à l’exécution.
Pour empêcher cela, on instrumente donc le retour de la fonction f pour que p soit remplacé
par NULL. Alors dans h, le déréférencement provoquera une erreur et empêchera la
violation du typage.48 CHAPITRE 4. SYNTAXE ET SÉMANTIQUE D’ÉVALUATION
f = Fun () {
Decl x = 0 in
return (&x);
}
g = Fun (p) {
Decl x = 0.0 in
*p = 1;
x <- x + 2.0; // (*)
}
h = Fun () {
Decl p = f() in
g(p);
}
FIGURE 4.10 : Cassage du typage par un pointeur fou
Pour définir l’opération de nettoyage, on commence par définir une opération de nettoyage
selon un prédicat sur les chemins :
CleanVP(p,c�) = c�
CleanVP(p, f
�) = f
�
CleanVP(p,&� ϕ) =
�
NULL si p(ϕ)
&� ϕ sinon
CleanVP(p,{l1 : v�1,...,ln : vn}) = {l1 : CleanVP(p, v1
�),...,ln : CleanVP(p, vn)}
CleanVP(p,[v�1;...; vn]) = [CleanVP(p, v1
�);...;CleanVP(p, vn)]
On l’étend ensuite aux cadres de pile, puis aux états mémoire :
CleanLP(p, (x1 �→ v1,...,xn �→ vn)) = (x1 �→ CleanVP(p, v1),...,xn �→ CleanVP(p, vn))
CleanP(p, (s, g )) = (s�
, g �
)
où s� = (CleanLP(p,s1),...,CleanLP(p,s|s|))
g � = CleanLP(p, g )
À l’aide de ces fonctions, on définit quatre opérations permettant de nettoyer des états
mémoire ou des valeurs en enlevant tout un niveau de pile ou seulement une variable :
Cleanup(m) = CleanP(λϕ.HΦ(ϕ) > |m|,m) CleanVar(m,a) = CleanP(λϕ.ϕ = a,m)
CleanVm(v) = CleanVP(λϕ.HΦ(ϕ) > |m|, v) CleanVarV(v,a) = CleanVP(λϕ.ϕ = a, v)
Ces 4 fonctions seront utilisées dans plusieurs règles dans la suite de ce chapitre.4.7. ACCESSEURS 49
Remarques Ces opérations ne sont pas toujours bien définies. Par exemple, Extend(·,· �→ ·)
ne peut pas s’appliquer à une pile vide, et m − x n’est défini que si une variable x existe au
sommet de la pile de m. Ce caractère partiel ne pose pas de problème de par la structure
des règles qui vont utiliser ces constructions. Par exemple, à chaque empilement correspond
exactement un dépilement. De plus, les phrases d’un programme ne peuvent pas faire intervenir
de déclaration de variable (une instruction est forcément dans une fonction), donc
Extend(·,· �→ ·) réussit toujours.
Un autre problème se pose si deux variables ont le même nom dans un cadre. Elles ne
peuvent pas être distinguées. On interdit donc ce cas en demandant aux programmes d’être
bien formés : au sein d’une fonction, les paramètres ainsi que l’ensemble des locales déclarées
doivent être de noms différents. En pratique, une phase préalable d’α-conversion peut
renommer les variables problématiques.
De plus, le fait d’ajouter cette étape de nettoyage à chaque retour de fonction peut être
assez coûteux. C’est un compromis : si on considère que les programmes se comportent bien
et ne créent pas de pointeurs fous (pointant au-dessus de la pile), alors cette phase est inutile
et peut être remplacée par l’identité. Autrement dit, il s’agit seulement d’une technique pour
s’assurer de ne pas avoir d’erreurs dans la sémantique. L’ajout d’un ramasse-miette, ou une
vérification préalable par un système de régions [TJ92], peut garantir qu’il n’y a pas de telles
constructions dangereuses.
4.7 Accesseurs
Le but de cette section est de définir rigoureusement les accès à la mémoire. À partir d’un
état mémoire m et d’une valeur gauche ϕ, on veut pouvoir définir une lentille Φ, permettant
d’obtenir :
• la valeur accessible au chemin ϕ : m[ϕ]Φ
• l’état mémoire obtenu en remplaçant celle-ci par une nouvelle valeur v� : m[ϕ ← v�
]Φ
Pour définir cette lentille indexée Φ, on commence par définir des lentilles élémentaires,
et on les compose pour pouvoir définir des lentilles entre valeurs.
On commence par définir deux lentilles I et L pour accéder aux structures de listes. I
accède par indice et L par clef (dans une liste d’association, donc).
Cela permet ensuite de définir A, qui extrait une valeur à partir d’un nom de variable et
d’une éventuelle hauteur de pile. Pour cela, on compose les lentilles I et L.
Les autres travaillent sur des valeurs composées, c’est-à-dire sur les structures et tableaux.
La lentille F extrait une sous-valeur correspondant au champ d’une structure. Le fonctionnement
est similaire à la lentille L puisqu’on accède par nom à une sous-structure. La lentille
T , quant à elle, permet d’accéder au ne élement d’un tableau. De ce point de vue, elle est
similaire à I mais en travaillant sur les valeurs.
Enfin, on définit Φ pour accéder à n’importe quelle sous-valeur d’une variable dans la
mémoire. Cela utilise A, F et T précédemment définis.
La figure 4.11 résume ces dépendances. Les lignes pleines indiquent quelles sont les défi-
nitions utilisées, et les pointillés relient les lentilles similaires. À droite, on donne un exemple
des lentilles de base. La valeur entourée correspond au « curseur » de la lentille, c’est-à-dire
la valeur qui peut être renvoyée ou mise à jour.50 CHAPITRE 4. SYNTAXE ET SÉMANTIQUE D’ÉVALUATION
I
L
F
T
A
Φ
I(3) : (3, 14, 15 , 92, 65)
L(toto) : ((toto, 3 ), (t at a, 6), (t i t i, 2))
F(y) : {x : 0; y : -3 }
T (0) : [ 1 ; 2; 3; 5]
FIGURE 4.11 : Dépendances entre les lentilles
Accès à une liste par indice : I
On définit une lentille indexée I : N → LENSα∗,α permettant d’accéder aux éléments d’une
liste par leur indice. On rappelle que les listes sont des suites finies, définies page 36. En outre,
I n’est définie que pour n ∈ [1;|l|].
l[n]I = ln si n ∈ [1;|l|]
l[n ← x]I = l
�
où l
�
n = x
∀i �= n,l
�
i = li
Accès à une liste d’associations : L
Une liste d’association est une liste de paires (clef, valeur) avec l’invariant supplémentaire
que les clefs sont uniques. Il est donc possible de trouver au plus une valeur associée à une
clef donnée. L’écriture est également possible, en remplaçant un couple par un couple avec
une valeur différente.
l[x]L =
�
v si ∃!n ∈ [1;|l|],∃v,ln = (x �→ v)
Ωvar sinon
l[x ← v�
]L =
�
l[n ← (x �→ v�
)]I si ∃!n ∈ [1;|l|],∃v,ln = (x �→ v)
Ωvar sinon
Accès par adresse : A
Les états mémoire sont constitués des listes d’association (nom, valeur).
L’accesseur par adresse [·]A permet de généraliser l’accès à ces valeurs en utilisant comme
clef non pas un nom mais une adresse.
Selon cette adresse, on accède soit à la liste des variables globales, soit à une des listes de
la pile des variables locales.
On pose m = (s, g ).
Les accès aux variables globales se font de la manière suivante. Si la variable n’existe pas,
notons que L retourne Ωvar .4.7. ACCESSEURS 51
A((x)) = Snd≫L(x)
Snd désigne la lentille entre un couple et sa deuxième composante. Ainsi, par exemple
m[(x) ← v]A = (s, g [x ← v]L).
Les accès aux locales reviennent à accéder à la bonne variable du bon cadre de pile. Cela
revient naturellement à composer les lentilles L et I. On définit donc une lentille Ls,n,x =
I(|s|−n +1)≫L(x) qui accède à la variable x du ne cadre de pile.
m[(n,x)]A =
�
getLs,n,x (s) si n ∈ [1;|s|]
Ωvar sinon
m[(n,x) ← v]A =
�
(putLs,n,x (v,s), g ) si n ∈ [1;|s|]
Ωvar sinon
Les numéros de cadre qui permettent d’identifier les locales (le n dans (n,x)) croissent
avec la pile. D’autre part, l’empilement se fait en tête de liste (près de l’indice 1). Donc pour
accéder aux plus vieilles locales (numérotées 1), il faut accéder au dernier élément de la liste.
Ceci explique pourquoi un indice |s|−n +1 apparaît dans la définition précédente.
Accès par champ : F
Les valeurs qui sont des structures possèdent des sous-valeurs, associées à des noms de
champ.
L’accesseur [·]F permet de lire et de modifier un champ de ces valeurs.
L’erreur Ωf i eld est levée si on accède à un champ non existant.
{l1 : v1;...;ln : vn}[l]F = vi si ∃i ∈ [1;n],l = li
{l1 : v1;...;ln : vn}[l]F = Ωf i eld sinon
{l1 : v1;...;ln : vn}[l ← v]F = {l1 : v1
;...
;lp−1 : vp−1
;lp : v
;lp+1 : vp+1
;...
;ln : vn} si ∃p ∈ [1;n],l = lp
{l1 : v1;...;ln : vn}[l ← v]F = Ωf i eld sinon
Accès par indice de tableau : T
On définit de même un accesseur [·]T pour les accès par indice à des valeurs tableaux.
Néanmoins le paramètre indice est toujours un entier et pas une expression arbitraire. Notons
que les accès sont vérifiés dynamiquement : il ne peut pas y avoir de débordement de
tableau.52 CHAPITRE 4. SYNTAXE ET SÉMANTIQUE D’ÉVALUATION
[v1;...; vn][i]T = vi+1 si i ∈ [0;n −1]
[v1;...; vn][i]T = Ωar r ay sinon
[v1;...; vn][i ← v]T = [v�
1;...; v�
n] si i ∈ [0;n −1]
où �
v�
i = v
∀j �= i, v�
j = v j
[v1;...; vn][i ← v]T = Ωar r ay sinon
Accès par chemin : Φ
L’accès par chemin Φ permet de lire et de modifier la mémoire en profondeur.
On peut accéder directement à une variable, et les accès à des sous-valeurs se font en
composant les accesseurs (définition 4.3, page 39) :
Φ(a) = A(a)
Φ(ϕ.l) = Φ(ϕ)≫F(l)
Φ(ϕ[i]) = Φ(ϕ)≫T (i)
Remarque Dans toute la suite, lorsque ce n’est pas ambigü, on emploiera la notation m[ϕ]
pour désigner m[ϕ]Φ. Il est important de remarquer que m désigne un état particulier et ϕ
un chemin particulier, mais que Φ est la lentille indexée globale définie page 52.
4.8 Contextes d’évaluation
L’évaluation des expressions repose sur la notion de contextes d’évaluation. L’idée est
que, si on peut évaluer une expression, alors on peut évaluer une expression qui contient
celle-ci.
Par exemple, supposons que 〈f (3),m〉 → 〈2,m〉. Alors on peut ajouter la constante 1 à
gauche de chaque expression sans changer le résultat : 〈1+ f (3),m〉 → 〈1+2,m〉. On a utilisé
le même contexte C = 1+ •.
Pour pouvoir raisonner en termes de contextes, 3 points sont nécessaires :
• comment découper une expression selon un contexte ;
• comment appliquer une règle d’évaluation sous un contexte ;
• comment regrouper une expression et un contexte.
Le premier point consiste à définir les contextes eux-mêmes (figure 4.12).
Dans cette définition, chaque cas hormis le cas de base fait apparaître exactement un
«C ». Chaque contexte est donc constitué d’exactement une occurrence de • (une dérivation
de C est toujours linéaire). L’opération de substitution consiste à remplacer ce trou : C�X� est
l’objet syntaxique (instruction, expression ou valeur gauche) obtenu en remplaçant l’unique
• dans C par X. Par exemple, DECL x = 2+ • IN{PASS}�5� est DECL x = 2+5 IN{PASS}
À titre d’illustration, décomposons l’évaluation de e1 � e2 en v = v1 �� v2 depuis un état
mémoire m :4.8. CONTEXTES D’ÉVALUATION 53
Contextes C ::= •
| C � e
| v � C
| � C
| & C
| C ← e
| ϕ ←C
| {l1 : v1;...;li :C;...;ln : en}
| [v1;...;C;...;en]
| C(e1,...,en)
| f (v1,...,C,...,en)
| C.lS
| C[e]
| ϕ[C]
| ∗ C
| C;i
| IF(C){i1}ELSE{i2}
| RETURN(C)
| DECL x = C IN{i}
FIGURE 4.12 : Contextes d’évaluation
1. on commence par évaluer l’expression e1 en une valeur v1. Le nouvel état mémoire est
noté m�
. Soit donc 〈e1,m〉 → 〈v1,m�
〉.
2. En appliquant la règle CTX (définie ci-après) avec C = • � e2 (qui est une des formes
possibles pour un contexte d’évaluation), on déduit de 1. que 〈e1 � e2,m〉 → 〈v1 � e2,m�
〉
3. D’autre part, on évalue e2 depuis m�
. En supposant encore que l’évaluation converge,
notons v2 la valeur calculée et m�� l’état mémoire résultant : 〈e2,m�
〉 → 〈v2,m��〉.
4. Appliquons la règle CTX à 3. avec C = v1 � •. On obtient 〈v1 � e2,m〉 → 〈v1 � v2,m�
〉.
5. En combinant les résultats de 2. et 4. on en déduit que 〈e1 � e2,m〉 → 〈v1 � v2,m��〉.
6. D’après la règle EXP-BINOP (page 55), 〈v1 � v2,m��〉 → 〈v1 �� v2,m��〉
7. D’après 5. et 6., on a par combinaison 〈e1 � e2,m〉 → 〈v,m��〉 en posant v = v1 �� v2.
Le deuxième point sera résolu par la règle d’inférence suivante.
〈i,m〉 → 〈i
�
,m�
〉
〈C�i�,m〉 → 〈C�i
�
�,m�
〉
(CTX)
Enfin, le troisième revient à définir l’opérateur de substitution ·�·� présent dans la règle
précédente. Notons que puisque i ::= e et e ::= l v, on peut aussi l’appliquer aux expressions
et aux valeurs gauches : l’opération ·�·� est purement syntaxique.54 CHAPITRE 4. SYNTAXE ET SÉMANTIQUE D’ÉVALUATION
4.9 Valeurs gauches
Obtenir un chemin à partir d’un nom de variable revient à résoudre le nom de cette variable
: est-elle accessible ? Le nom désigne-t-il une variable locale ou une variable globale ?
a = Lookup(x,m)
〈x,m〉 → 〈a,m〉
(PHI-VAR)
Les règles portant sur le déréférencement et l’accès à un champ de structure sont similaires
: on commence par évaluer la valeur gauche sur laquelle porte ce modificateur, et on
place le même modificateur sur le chemin résultant. Dans le cas des champs de structure,
l’annotation de structure S n’est pas prise en compte pour l’évaluation : elle servira uniquement
au typage.
〈ϕ.lS,m〉 → 〈ϕ�.l,m〉
(PHI-STRUCT)
Enfin, pour évaluer un chemin dans un tableau, on commence par procéder comme pré-
cédemment, c’est-à-dire en évaluant la valeur gauche sur laquelle porte l’opération d’indexation.
Puis on évalue l’expression d’indice en une valeur qui permet de construire le chemin
résultant.
〈ϕ[n],m〉 → 〈ϕ[
�n],m〉
(PHI-ARRAY)
Notons qu’en procédant ainsi, on évalue les valeurs gauches en allant de gauche à droite :
dans l’expression x[e1][e2][e3], e1 est évalué en premier, puis e2, puis e3.
La règle portant sur le déréférencement est particulière. On peut penser que la bonne
définition de ϕ consiste à se calquer sur la définition de l v, en remplaçant les noms de variable
par leur adresse résolue et en évaluant les indices de tableau, et à ajouter une règle qui
transforme ∗ϕ en ∗�ϕ. Or, cela ne fonctionne pas, car alors les déréférencements sont évalués
trop tard : au moment de l’affectation dans la valeur gauche plutôt qu’à sa définition. La
figure 4.13 illustre ce problème.
Decl s0 = { .f : 0 } in
Decl s1 = { .f : 1 } in
Decl x = & s0 in
Decl p = & ((*x).f) in
/* (a) */
x <- & s1
/* (b) */
FIGURE 4.13 : Évaluation stricte ou paresseuse des valeurs gauches
On s’intéresse à l’évaluation de l’expression *p aux points (a) et (b). Avec une sémantique
paresseuse (en ajoutant un ∗�ϕ), la valeur de p est & (( � ∗(1,x)).f ), donc *p est évalué à 0
en (a) et 1 en (b). Au contraire, avec une sémantique stricte (correcte), p vaut & (((1, � s0).f )
et donc *p est évalué à 0 en (a) et en (b).
Dans le cas où la valeur référencée n’a pas la forme &�ϕ ou N
�ULL, aucune règle ne peut
s’appliquer (comme lorsqu’on cherche à réduire l’addition d’une fonction et d’un entier, par4.10. EXPRESSIONS 55
exemple). Cela est préférable à renvoyer Ωp t r car on montrera que ce cas est toujours évité
dans les programmes typés (théorème 5.1).
v = &� ϕ
〈∗ v,m〉 → 〈ϕ,m〉
(EXP-DEREF)
v = N
�ULL
〈∗ v,m〉 → Ωp t r
(EXP-DEREF-NULL)
Par exemple, l v = x.lS[2∗n].gT pourra s’évaluer en ϕ = (2,x).l[4].g .
4.10 Expressions
Évaluer une constante est le cas le plus simple, puisqu’en quelque sorte celle-ci est déjà
évaluée. À chaque constante syntaxique c, on peut associer une valeur sémantique c�. Par
exemple, au chiffre (symbole) 3, on associe le nombre (entier) �3.
〈c,m〉 → 〈c�,m〉
(EXP-CST)
De même, une fonction est déjà évaluée :
〈f ,m〉 → 〈f
�,m〉
(EXP-FUN)
Pour lire le contenu d’un emplacement mémoire (valeur gauche), il faut tout d’abord
l’évaluer en un chemin.
〈ϕ,m〉 → 〈m[ϕ]Φ,m〉
(EXP-LV )
Pour évaluer une expression constituée d’un opérateur, on évalue une sous-expression,
puis l’autre (l’ordre d’évaluation est encore imposé : de gauche à droite). À chaque opérateur
�, correspond un opérateur sémantique �� qui agit sur les valeurs. Par exemple, l’opérateur +�
est l’addition entre entiers machine (page 43). Comme précisé dans la section 4.5, la division
par zéro via /, % ou /. provoque l’erreur Ωd i v .
〈� v,m〉 → 〈�� v,m〉
(EXP-UNOP)
〈v1 � v2,m〉 → 〈v1 �� v2,m〉
(EXP-BINOP)
Il est nécessaire de dire un mot sur les opérations +�p et −�p définissant l’arithmétique
des pointeurs. Celles-ci sont uniquement définies pour les références mémoire à un tableau,
c’est-à-dire celles qui ont la forme &� ϕ[n]. On a alors :
&� ϕ[n] +�p i = &� ϕ[n +� i]
&� ϕ[n] ] −�p i = &� ϕ[n −� i]
Cela implique qu’on ne peut pas faire d’arithmétique de pointeurs au sein d’une même
structure. Autrement c’est une erreur de manipulation de pointeurs 3 et l’opérateur �� renvoie
Ωp t r .
3. Cela est cohérent avec la norme C99 : « If the pointer operand points to an element of an array object, and
the array is large enough, [. . . ] ; otherwise, the behavior is undefined. » [ISO99, 6.5.6 §8]56 CHAPITRE 4. SYNTAXE ET SÉMANTIQUE D’ÉVALUATION
Si l’indice calculé (n +� i ou n −� i) sort de l’espace alloué, alors l’erreur sera faite au moment
de l’accès : la lentille T renverra Ωar r ay (page 51).
Une left-value s’évalue en le chemin correspondant.
〈& ϕ,m〉 → 〈&� ϕ,m〉
(EXP-ADDR)
L’affectation se déroule en 3 étapes. D’abord, l’expression est évaluée en une valeur v.
Ensuite, la valeur gauche est évaluée en un chemin ϕ. Enfin, un nouvel état mémoire est
construit, où la valeur accessible par ϕ est remplacée par v. Comme dans le langage C, l’expression
d’affectation produit une valeur, qui est celle qui a été affectée.
〈ϕ ← v,m〉 → 〈v,m[ϕ ← v]Φ〉
(EXP-SET)
Expressions composées
Les littéraux de structures sont évalués en leurs constructions syntaxiques respectives.
Puisque les contextes d’évaluation sont de la forme [v1;...;C;...;en], l’évaluation se fait toujours
de gauche à droite.
〈{l1 : v1;...;ln : vn},m〉 → 〈{l1 : v�1;...;ln : vn},m〉
(EXP-STRUCT)
〈[v1;...; vn],m〉 → 〈[v�1;...; vn],m〉
(EXP-ARRAY)
L’appel de fonction est traité de la manière suivante. On ne peut pas facilement relier
un pas d’évaluation de i à un pas d’évaluation de fun(a){i}(v1,..., vn), et donc un contexte
C ::= fun(a){•}(v1,..., vn) n’est pas à considérer. En effet, l’empilement suivi du dépilement
modifie la mémoire.
On emploie donc une règle EXP-CALL-CTX qui relie un pas interne 〈i,m1〉 → 〈i�
,m2〉 à un
pas externe. Une fois l’instruction interne réduite d’un pas, on évalue les arguments en des
valeurs v�
i
. Ils correspondent aux nouvelles valeurs à passer à la fonction.
Les autres règles permettent de transférer le flot de contrôle : en retournant la même
instruction pour une instruction terminale, ou en propageant une erreur. Dans le cas où on
retourne de la fonction pari = RETURN(v), il faut alors supprimer les références aux variables
qui ont disparu grâce aux opérateurs Cleanup(·) et CleanV·(·).
On suppose deux choses sur chaque fonction : d’une part, les noms de ses arguments sont
deux à deux différents et, d’autre part, son corps se termine par une instruction RETURN(·).
Cela veut dire que la dernière instruction doit être soit de cette forme, soit par exemple une
alternative dans laquelle les deux branches se terminent par un RETURN(·). C’est une propriété
qui peut être détectée statiquement avant l’exécution. Néanmoins, dans la syntaxe
concrète, on peut supposer qu’un RETURN(( )) est inséré automatiquement en fin de fonction
lorsqu’aucun RETURN(·) n’est présent dans son corps.4.11. INSTRUCTIONS 57
m1 = Push(m0, ((a1 �→ v1),..., (an �→ vn)))
〈i,m1〉 → 〈i
�
,m2〉 ∀i ∈ [1;n], v�
i = m2[(|m2|,ai)]A m3 = Pop(m2)
〈fun(a1,...,an){i}(v1,..., vn),m0〉 → 〈fun(a1,...,an){i
�
}(v�
1,..., v�
n),m3〉
(EXP-CALL-CTX)
m� = Push(m, ((a1 �→ v1),..., (an �→ vn))) 〈i,m�
〉 → Ω
〈fun(a1,...,an){i}(v1,..., vn),m〉 → Ω
(EXP-CALL-ERR)
m� = Cleanup(m) v� = CleanV|m|(v)
〈fun(a1,...,an){RETURN(v)}(v1,..., vn),m〉 → 〈v�
,m�
〉
(EXP-CALL-RETURN)
4.11 Instructions
Les cas de la séquence et de l’évaluation d’une expression sont sans surprise.
〈(PASS;i),m〉 → 〈i,m〉
(SEQ)
〈v,m〉 → 〈PASS,m〉
(EXP)
L’évaluation de DECL x = v IN{i} sous m se fait de la manière suivante, similaire à l’appel
de fonction. La règle principale est DECL-CTX qui relie un pas d’évaluation sous une déclaration
à un pas d’évaluation externe : pour ce faire, on étend l’état mémoire en ajoutant x,
on effectue le pas, puis on enlève x. L’instruction résultante est la déclaration de x avec la
nouvelle valeur v� de x après le pas d’exécution 4.
On suppose qu’il n’y a pas de masquage au sein d’une fonction, c’est-à-dire que le nom
d’une variable déclarée n’est jamais dans l’environnement avant cette déclaration.
Si i est terminale (PASS ou RETURN(v)), alors on peut l’évaluer en i en nettoyant l’espace
mémoire des références à x qui peuvent subsister.
Enfin, si une erreur se produit elle est propagée.
m� = CleanVar(m − x, (|m|,x))
〈DECL x = v IN{PASS},m〉 → 〈PASS,m�
〉
(DECL-PASS)
m� = CleanVar(m − x, (|m|,x)) v�� = CleanVarV(v�
, (|m|,x))
〈DECL x = v IN{RETURN(v�
)},m〉 → 〈RETURN(v��),m�
〉
(DECL-RETURN)
m� = Extend(m,x �→ v)
〈i,m�
〉 → 〈i
�
,m��〉 v� = m��[(|m��|,x)]A m��� = m�� − x
〈DECL x = v IN{i},m〉 → 〈DECL x = v� IN{i
�
},m���〉
(DECL-CTX)
〈i,m〉 → Ω
〈DECL x = v IN{i},m〉 → Ω
(DECL-ERR)
Pour traiter l’alternative, on a besoin de 2 règles. Elles commencent de la même manière,
en évaluant la condition. Si le résultat est 0 (et seulement dans ce cas), c’est la règle IF-FALSE
4. On peut remarquer qu’il est impossible de définir un contexte d’évaluation C ::= DECL x = v IN{C}. En
effet, puisque celui-ci nécessiterait d’ajouter une variable, il ne préserve pas la mémoire.58 CHAPITRE 4. SYNTAXE ET SÉMANTIQUE D’ÉVALUATION
qui est appliquée et l’instruction revient à évaluer la branche « else ». Dans les autres cas, c’est
la règle IF-TRUE qui s’applique et la branche « then » qui est prise.
〈IF(0){it }ELSE{if },m〉 → 〈if ,m〉
(IF-FALSE)
v �= 0
〈IF(v){it }ELSE{if },m〉 → 〈it ,m〉
(IF-TRUE)
On exprime la sémantique de la boucle comme une simple règle de réécriture :
〈WHILE(e){i},m〉 → 〈IF(e){i;WHILE(e){i}}ELSE{PASS},m〉
(WHILE)
Enfin, si un RETURN(·) apparaît dans une séquence, on peut supprimer la suite :
〈RETURN(v);i,m〉 → 〈RETURN(v),m〉
(RETURN)
4.12 Erreurs
Les erreurs se propagent des données vers l’interprète ; c’est-à-dire que si une expression
ou instruction est réduite en une valeur d’erreur Ω, alors une transition est faite vers cet état
d’erreur.
Cela est aussi vrai d’une sous-expression ou sous-instruction : si l’évaluation de e1 provoque
une erreur, l’évaluation de e1 + e2 également. La notion de sous-expression ou sous
instruction est définie en fonction des contextes C. Notons que, dans EVAL-ERR, C�e� peut
être une expression ou une instruction.
〈Ω,m〉 → Ω
(EXP-ERR)
〈e,m〉 → Ω
〈C�e�,m〉 → Ω
(EVAL-ERR)
4.13 Phrases et exécution d’un programme
Un programme est constitué d’une suite de phrases qui sont soit des déclarations de variables
(dont les fonctions), soit des évaluations d’expressions.
Contrairement à C, il n’y a pas de déclaration de types au niveau des phrases (permettant
par example de définir les types structures et leurs champs). On suppose que, dans une
étape précédente, chaque accès à un champ de structure a été décoré du type complet correspondant.
Par exemple, il est possible de compiler vers SAFESPEAK un langage comportant
des accès non décorés, mais où les types de structures sont déclarés. Le compilateur est alors
capable de repérer à quel type appartiennent quels champs et d’émettre ces étiquettes. C’est
d’ailleurs une des étapes de la compilation d’un programme C.
L’évaluation d’une phrase p fait donc passer d’un état mémoire m à un autre m�
, ce que
l’on note m � p → m�
.
L’évaluation d’une expression est uniquement faite pour ses effets de bord. Par exemple,
après avoir défini les fonctions du programme, on pourra appeler main(). La déclaration
d’une variable globale, quant à elle, consiste à évaluer sa valeur initiale et à étendre l’état
mémoire avec ce couple (variable, valeur). On suppose que les variables globales ont toutes
des noms différents. Notons que ces évaluations se font à grands pas.4.14. EXEMPLE 59
Enfin, l’exécution d’un programme, notée � P →∗ m, permet de construire un état mé-
moire final. Cette relation →∗ est l’extension de → sur les suites de phrases, c’est-à-dire les
programmes.
〈e,m〉 → 〈v,m�
〉
m � e → m� (ET-EXP)
〈e,m〉 → 〈v,m�
〉 m� = (s, g ) m�� = (s, (x �→ v) :: g )
m � x = e → m�� (ET-VAR)
([ ], [ ]) � p1 → m1 m1 � p2 → m2 ... mn−1 � pn → mn
� p1,...,pn →∗ m
(PROG)
4.14 Exemple
Considérons le programme suivant :
(p1) s = { x: 0; y: 0}
(p2) f = fun(q) {
*q <- 1
}
(p3) f(&s.x)
Ce programme est constitué des phrases p1,p2 et p3. On rappelle que par rapport à la syntaxe
concrète, un RETURN(( )) est inséré automatiquement, donc p2 est en fait f = fun(q){∗q ←
1; RETURN(( ))}. De plus, un prétraitement va annoter l’accès à s.x en rajoutant le type structure
de s, noté S.
D’après PROG, l’évaluer va revenir à évaluer à la suite ces 3 phrases.
Déclaration de x D’après PROG, on part d’un état mémoire m0 = ([ ], [ ]). Pour trouver m1
tel que m0 � s = {x : 0; y : 0} → m1, il faut appliquer la règle ET-VAR. Celle-ci va étendre l’ensemble
(vide) des globales mais demande d’évaluer l’expression 0. D’après EXP-CST,〈0,m0〉 →
〈�0,m0〉.
Donc m0 � s = {x : 0; y : 0} → m1 en posant m1 = ([ ], [s �→ {x�: 0; y : 0}]).
Déclaration de f On se trouve encore dans le cas de la déclaration d’une variable globale.
Il faut comme auparavant évaluer l’expression. C’est la règle EXP-FUN qui s’applique :
〈fun(q){∗q ← 1; RETURN(( ))},m1〉 → 〈fun(q){∗q�← 1; RETURN(( ))},m1〉 (ce qui revient à dire
que le code de la fonction est directement placé en mémoire).
Ainsi m1 � f = fun(q){∗q ← 1; RETURN(( ))} → m2 où :
m2 = ([ ], [f �→ fun(q){∗q�← 1; RETURN(( ))};s �→ {x�: 0; y : 0}])
Appel de f Ici, on évalue une expression pour ses effets de bords. La règle à appliquer est
ET-EXP, qui a comme prémisse 〈f (&s.xS),m2〉 → 〈v,m3〉.
D’après la forme de l’expression, la règle à appliquer va être EXP-CALL-RETURN. Mais il
va falloir d’abord réécrire l’expression à l’aide de CTX (pour que l’expression appellée ait la60 CHAPITRE 4. SYNTAXE ET SÉMANTIQUE D’ÉVALUATION
forme f
�et l’argument soit évalué) et EXP-CALL-CTX (pour que le corps de la fonction ait pour
forme RETURN(( ))).
Tout d’abord on applique donc CTX avec C = •(&s.xS). Comme on a via PHI-VAR puis
EXP-LV :
〈f ,m2〉 → 〈fun(q){∗q�← 1; RETURN(( ))},m2〉
On en déduit que :
〈f (&s.xS),m2〉 → 〈fun(q){∗q�← 1; RETURN(( ))}(&s.xS),m2〉
On évalue ensuite &s.xS. Les règles à appliquer sont EXP-ADDR et PHI-STRUCT. On en
déduit que 〈&s.xS,m2〉 → 〈&( � s).x,m2〉. Remarquons que l’étiquette de type structure S a été
effacée. Une application supplémentaire de CTX permet d’en arriver à la ligne suivante :
〈f (&s.xS),m2〉 → 〈fun(q){∗q�← 1; RETURN(( ))}(&( � s).x),m2〉
La fonction et son argument sont évalués, donc on peut appliquer EXP-CALL-CTX. En
posant m�
2 = Push(m2, (q �→ &( � s).x)), le but est de trouver m��
2 et v tels que :
〈∗q ← 1; RETURN(( )),m�
2〉 → 〈RETURN(v),m��
2 〉
Puisque l’instruction est une séquence, on va appliquer SEQ. La première partie n’étant
pas PASS, il faut l’évaluer grâce à la règle CTX avec C = •; RETURN(( )).
Le nouveau but est de trouver un m��
2 tel que 〈∗q ← 1,m�
2〉 → 〈PASS,m��
2 〉.
En appliquant EXP-DEREF sous C = ∗• ← 1, on obtient 〈∗q ← 1,m�
2〉 → 〈(s).x ← 1,m�
2〉.
Puis on applique EXP-CST sous C = (s).x ← • et 〈∗q ← 1,m�
2〉 → 〈(s).x ← �1,m�
2〉.
Maintenant que les deux côtés de ← sont évalués, on peut appliquer EXP-SET, et 〈∗q ←
1,m�
2〉 → 〈PASS,m��
2 〉 où :
m��
2 = ([[q �→ &( � s).x]], [f �→ fun(q){∗q�← 1; RETURN(( ))};s �→ {x�: 0; y : 0}])
Alors, d’après SEQ, 〈∗q ← 1,m�
2〉 → 〈RETURN(()),m��
2 〉. Avec EXP-CST sous C = RETURN(•),
on a donc 〈∗q ← 1,m�
2〉 → 〈RETURN(()), � m��
2 〉.
On peut enfin appliquer EXP-CALL-CTX pour en déduire que :
〈f (&s.xS),m2〉 → 〈fun(q){RETURN(( ))}( � &( � s).x),m���
2 〉
Donc d’après EXP-CALL-RETURN (car on a m���
2 = Cleanup(m��
2 ) et ( )� = CleanV|m���
2 |(( ))) : �
〈f (&s.xS),m2〉 → 〈( ), � m���
2 〉
En posant m3 = m���
2 , on a m2 � f (&s.xS) → m3.
Donc pour conclure (grâce à PROG), on a � [p1,p2,p3] →∗ m3.
Conclusion
On vient de définir un langage impératif, SAFESPEAK. Le but est que celui-ci serve de support
à des analyses statiques, afin notamment de montrer une propriété de sécurité sur les
pointeurs. Pour le moment, on a seulement défini ce que sont les programmes (leur syntaxe)
et comment ils s’exécutent (leur sémantique). Sur ces deux points, on note que nous sommes4.14. EXEMPLE 61
restés suffisamment proches de C, tout en utilisant pour la mémoire un modèle plus structuré
qu’une simple suite d’octets. Les définitions de la syntaxe ainsi que de la sémantique
sont rappelées dans l’annexe B (sections B.1 à B.7).
Afin de manipuler les états mémoire dans la sémantique d’évaluation, nous avons utilisé
le concept des lentilles, qui permettent de chaîner des accesseurs entre eux et d’accéder simplement
à des valeurs profondes de la mémoire, en utilisant le même outil pour la lecture et
l’écriture.
Pour le moment, on ne peut rien présager de l’exécution d’un programme bien formé
syntaxiquement. Pour la grande majorité des programmes bien formés (à la syntaxe correcte),
l’évaluation s’arrêtera soit par une erreur, soit parce qu’aucune règle d’évaluation ne peut
s’appliquer. Dans les chapitres 5 et 6, nous allons donc définir un système de types qui permet
de rejeter ces programmes se comportant mal à l’exécution.C H A P I T R E
5
TYPAGE
Dans ce chapitre, nous enrichissons le langage défini dans le chapitre 4 d’un système
de types. Celui-ci permet de séparer les programmes bien formés, comme celui de la fi-
gure 5.1(a), des programmes mal formés, comme celui de la figure 5.1(b). Intuitivement, le
programme mal formé provoquera des erreurs à l’exécution car il manipule des données de
manière incohérente : la variable x reçoit 1, donc elle se comporte comme un entier, puis est
déférencée, se comportant comme un pointeur.
f = Fun() {
Decl x = 0 in
x <- 1
return x
}
(a) Programme bien formé
f = Fun() {
Decl x = 0 in
x <- 1
return (*x)
}
(b) Programme mal formé
FIGURE 5.1 : Programmes bien et mal formés
Le but d’un tel système de types est de rejeter les programmes pour lesquels on peut facilement
déterminer qu’ils sont faux, c’est-à-dire dont on peut prouver qu’ils provoqueraient
des erreurs à l’exécution dues à une incompatibilité entre valeurs. En ajoutant cette étape, on
restreint la classe d’erreurs qui pourraient bloquer la sémantique.
On emploie un système de types monomorphe : à chaque expression, on associe un
unique type. En plus des types de base INT, FLOAT et UNIT, on peut construire des types
composés : pointeurs, tableaux, structures et fonctions.
Pour typer les structures, on suppose que les accès aux champs sont décorés du type complet
de la structure. Cela permet de typer sans ambigüité ces accès. Dans l’implantation dé-
crite dans le chapitre 7, ces annotations ne sont pas présentes. On y utilise donc une variante
du polymorphisme de rangée [RV98] présent dans OCaml pour unifier deux types structures
partiellement connus.
Le principe du typage est d’associer à chaque construction syntaxique une étiquette représentant
le genre de valeurs qu’elle produira. Dans le programme de la figure 5.1(a), la
variable x est initialisée avec la valeur 0 ; c’est donc un entier. Cela signifie que, dans tout le
programme, toutes les instances de cette variable 1 porteront ce type. La première instruction
1. Deux variables peuvent avoir le même nom dans deux fonctions différentes, par exemple. Dans ce cas il n’y
a aucune contrainte particulière entre ces deux variables. L’analyse de typage se fait toujours dans un contexte
précis.
6364 CHAPITRE 5. TYPAGE
est l’affectation de la constante 1 (entière) à x dont on sait qu’elle porte des valeurs entières,
ce qui est donc correct. Le fait de rencontrer RETURN(x) permet de conclure que le type de la
fonction est ( ) → INT (c’est-à-dire qu’elle n’a pas d’arguments et qu’elle retourne un INT).
Dans la seconde fonction, au contraire, l’opérateur ∗ est appliqué à x (le début de l’analyse
est identique et permet de conclure que x porte des valeurs entières). Or cet opérateur
prend un argument d’un type pointeur de la forme t ∗ et renvoie alors une valeur de type t.
Ceci est valable pour tout t (INT, FLOAT ou même t� ∗ : le déréférencement d’un pointeur sur
pointeur donne un pointeur), mais le type de x, INT, n’est pas de cette forme. Ce programme
est donc mal typé.
Dans ce chapitre, on commence par poser les notations qui vont servir à définir la relation
de typage. Ensuite, on explique les différentes règles de typage sur les composantes de
SAFESPEAK : expressions, instructions et phrases. Enfin, dans le reste du chapitre on établit
des propriétés qui sont respectées par les programmes bien typés. On conclut par les théorèmes
de progrès et de préservation qui établissent la sûreté du typage.
5.1 Environnements et notations
Les types associés aux expressions sont décrits dans la figure 5.2. Tous sont des types
concrets : il n’y a pas de polymorphisme.
Type t ::= INT Entier
| FLOAT Flottant
| UNIT Unité
| t∗ Pointeur
| t [ ] Tableau
| S Structure
| (t1,...,tn) → t Fonction
Structure S ::= {l1 : t1;...;ln : tn}
FIGURE 5.2 : Types et environnements de typage
Pour maintenir les contextes de typage, un environnement Γ associe un type à un ensemble
de variables.
Plus précisément, un environnement Γ est composé de deux listes de couples (variable,
type) : une pour les variables locales, et une pour les variables globales. Cette distinction est
nécessaire pour les définitions de fonctions : on remplace la liste des variables locales, mais
on conserve le type des variables globales.
Si Γ = (ΓG ,ΓL) = ((γi)i∈[1;n], (ηi)i∈[1;m]), avec γi = (gi ,ti) et ηi = (li ,ui), on utilise les notations
suivantes :5.2. EXPRESSIONS 65
x : t ∈ Γ def
== ∃i ∈ [1;n],γi = (x,t)∨ ∃i ∈ [1;m],ηi = (x,t)
dom(ΓG ) def
== {gi /i ∈ [1;n]}
dom(ΓL) def
== {li /i ∈ [1;m]}
dom(Γ) def
== dom(ΓG )∪dom(ΓL)
Γ, global x : t def
== ((γ�
i)
i∈[1;n+1],ΓL) tel que�
∀i ∈ [1;n],γ�
i = γi
γn+1 = (x,t)
Γ,local x : t def
== (ΓG , (η�
i)
i∈[1;m+1]) tel que�
∀i ∈ [1;m],η�
i = ηi
ηn+1 = (x,t)
Le type des fonctions semble faire apparaître un n-uplet (t1,...,tn) mais ce n’est qu’une
notation : il n’y a pas de n-uplets de première classe ; ils sont toujours présents dans un type
fonctionnel.
Le typage correspond à la définition des trois jugements suivants. Les deux premiers sont
mutuellement récursifs car une instruction peut consister en l’évaluation d’une expression,
et la définition d’une fonction repose sur le typage de son corps.
Typage d’une expression : on note de la manière suivante le fait qu’une expression e (telle
que définie dans la figure 4.4) ait pour type t dans le contexte Γ.
Γ � e : t
Typage d’une instruction : les instructions n’ont en revanche pas de type. Mais il est tout
de même nécessaire de vérifier que toutes les sous-expressions apparaissant dans une instruction
sont cohérentes ensemble.
On note de la manière suivante le fait que sous l’environnement Γ l’instruction i est bien
typée :
Γ � i
Typage d’une phrase : De par leur nature séquentielle, les phrases qui composent un programme
altèrent l’environnement de typage. Par exemple, la déclaration d’une variable globale
ajoute une valeur dans l’environnement.
On note de la manière suivante le fait que le typage de la phrase p transforme l’environnement
Γ en Γ� :
Γ � p → Γ�
On étend cette notation aux suites de phrases, ce qui définit le typage d’un programme,
ce que l’on note � P.
5.2 Expressions
Littéraux
Le typage des littéraux numériques ne dépend pas de l’environnement de typage : ce sont
toujours des entiers ou des flottants.66 CHAPITRE 5. TYPAGE
Γ � n : INT
(CST-INT)
Γ � d : FLOAT
(CST-FLOAT)
Le pointeur nul, quant à lui, est compatible avec tous les types pointeur. Cependant, il
conserve bien un type monomorphe : le type t n’est pas généralisé.
Γ � NULL : t ∗ (CST-NULL)
Enfin, le littéral unité a le type UNIT.
Γ � ( ) : UNIT
(CST-UNIT)
Valeurs gauches
Rappelons que l’environnement de typage Γ contient le type des variables accessibles du
programme. Le cas où la valeur gauche à typer est une variable est donc direct : il suffit de
retrouver son type dans l’environnement.
x : t ∈ Γ
Γ � x : t
(LV-VAR)
Dans le cas d’un déréférencement, on commence par typer la valeur gauche déréférencée.
Si elle a un type pointeur, la valeur déréférencée est du type pointé.
Γ � e : t ∗
Γ � ∗e : t
(LV-DEREF)
Pour une valeur gauche indexée (l’accès à tableau), on s’assure que l’indice soit entier, et
que la valeur gauche a un type tableau : le type de l’élement est encore une fois le type de
base du type tableau.
Γ � e : INT Γ � l v : t[ ]
Γ � l v[e] : t
(LV-INDEX)
Le typage de l’accès à un champ est facilité par le fait que, dans le programme, le type
complet de la structure est accessible sur chaque accès.
Dans la définition de cette règle on utilise la notation :
(l,t) ∈ {l1 : t1;...;ln : tn} def
== ∃i ∈ [1;n],l = li ∧ t = ti
(l,t) ∈ S Γ � l v : S
Γ � l v.lS : t
(LV-FIELD)5.2. EXPRESSIONS 67
Opérateurs
Un certain nombre d’opérations est possible sur le type INT.
� ∈ {+,−,×,/,&,|,^,&&,||,�,�,≤,≥,<,>} Γ � e1 : INT Γ � e2 : INT
Γ � e1 � e2 : INT
(OP-INT)
De même sur FLOAT.
� ∈ {+.,−.,×.,/.,≤ .,≥ .,< .,> .} Γ � e1 : FLOAT Γ � e2 : FLOAT
Γ � e1 � e2 : FLOAT
(OP-FLOAT)
Les opérateurs de comparaison peuvent s’appliquer à deux opérandes qui sont d’un type
qui supporte l’égalité. Ceci est représenté par un jugement EQ(t) qui est vrai pour les types
INT, FLOAT et pointeurs, ainsi que les types composés si les types de leurs composantes le
supportent (figure 5.3). Les opérateurs = et �= renvoient alors un INT :
� ∈ {=,�=} Γ � e1 : t Γ � e2 : t EQ(t)
Γ � e1 � e2 : INT
(OP-EQ)
EQ(t)
t ∈ {INT, FLOAT}
EQ(t)
(EQ-NUM)
EQ(t ∗)
(EQ-PTR)
EQ(t)
EQ(t[ ])
(EQ-ARRAY)
∀i ∈ [1;n].EQ(ti)
EQ({l1 : t1;...ln : tn})
(EQ-STRUCT)
FIGURE 5.3 : Jugements d’égalité sur les types
Les opérateurs unaires « + » et « − » appliquent aux entiers, et leurs équivalents « +. » et
« −. » aux flottants.
Γ � e : INT
Γ � +e : INT
(UNOP-PLUS-INT)
Γ � e : FLOAT
Γ � +.e : FLOAT
(UNOP-PLUS-FLOAT)
Γ � e : INT
Γ � −e : INT
(UNOP-MINUS-INT)
Γ � e : FLOAT
Γ � −.e : FLOAT
(UNOP-MINUS-FLOAT)
Les opérateurs de négation unaires, en revanche, ne s’appliquent qu’aux entiers.
� ∈ {∼,!} Γ � e : INT
Γ � � e : INT
(UNOP-NOT)68 CHAPITRE 5. TYPAGE
L’arithmétique de pointeurs préserve le type des pointeurs.
� ∈ {+p,−p} Γ � e1 : t ∗ Γ � e2 : INT
Γ � e1 � e2 : t ∗ (PTR-ARITH)
Autres expressions
Prendre l’adresse d’une valeur gauche rend un type pointeur sur le type de celle-ci.
Γ � l v : t
Γ � &l v : t ∗ (ADDR)
Pour typer une affectation, on vérifie que la valeur gauche (à gauche) et l’expression (à
droite) ont le même type. C’est alors le type résultat de l’expression d’affectation.
Γ � l v : t Γ � e : t
Γ � l v ← e : t
(SET)
Un littéral tableau a pour type t[ ] où t est le type de chacun de ses éléments.
∀i ∈ [1;n],Γ � ei : t
Γ � [e1;...;en] : t[ ]
(ARRAY)
Un littéral de structure est bien typé si ses champs sont bien typés.
∀i ∈ [1;n],Γ � ei : ti
Γ � {l1 : e1;...;ln : en} : {l1 : t1;...;ln : tn}
(STRUCT)
Pour typer un appel de fonction, on s’assure que la fonction a bien un type fonctionnel.
On type alors chacun des arguments avec le type attendu. Le résultat est du type de retour de
la fonction.
Γ � e : (t1,...,tn) → t ∀i ∈ [1;n],Γ � ei : ti
Γ � e(e1,...,en) : t
(CALL)
5.3 Instructions
La séquence est simple à traiter : l’instruction vide est toujours bien typée, et la suite de
deux instructions est bien typée si celles-ci le sont également.
Γ � PASS
(PASS)
Γ � i1 Γ � i2
Γ � i1;i2
(SEQ)
Une instruction constituée d’une expression est bien typée si celle-ci peut être typée dans
ce même contexte.5.4. FONCTIONS 69
Γ � e : t
Γ � e
(EXP)
Une déclaration de variable est bien typée si son bloc interne est bien typé quand on
ajoute à l’environnement la variable avec le type de sa valeur initiale.
Γ � e : t Γ,local x : t � i
Γ � DECL x = e IN{i}
(DECL)
Les constructions de contrôle sont bien typées si leurs sous-instructions sont bien typées,
et si la condition est d’un type entier.
Γ � e : INT Γ � i1 Γ � i2
Γ � IF(e){i1}ELSE{i2}
(IF)
Γ � e : INT Γ � i
Γ � WHILE(e){i}
(WHILE)
5.4 Fonctions
Le typage des fonctions fait intervenir une variable virtuelle R. Cela revient à typer l’instruction
RETURN(e) comme R ← e. Cela rappelle le langage Pascal, où pour retourner une
valeur on l’affecte à une variable nommée comme la fonction courante 2.
R : t ∈ Γ Γ � e : t
Γ � RETURN(e)
(RETURN)
Pour typer une définition de fonction, on commence par créer un nouvel environnement
de typage Γ� obtenu par la suite d’opérations suivantes :
• on enlève l’ensemble des locales. Cela inclut le couple R : tf correspondant à la valeur
de retour de la fonction appelante.
• on ajoute les types des arguments ai : ti
• on ajoute le type de la valeur de retour de la fonction appelée, R : t
Si le corps de la fonction est bien typé sous Γ�
, alors la fonction est typable en (t1,...,tn) →
t sous Γ.
Γ = (ΓG ,ΓL) Γ� = (ΓG , [a1 : t1;...;an : tn;R : t]) Γ� � i
Γ � fun(a1,...,an){i} : (t1,...,tn) → t
(FUN)
5.5 Phrases
Le typage des phrases est détaillé dans la figure 5.4. Le typage d’une expression est le cas
le plus simple. En effet, il y a juste à vérifier que celle-ci est bien typable (avec ce type) dans
l’environnement de départ : l’environnement n’est pas modifié. En revanche, la déclaration
d’une variable globale commence de la même manière, mais on enrichit l’environnement de
typage des globales de cette nouvelle association.
2. Si on n’avait pas introduit la restriction que chaque fonction doit terminer par un RETURN(·) (page 56),
alors le type de R pourrait rester inconnu. En pratique cela veut dire que la valeur de retour d’une telle fonction
serait compatible avec n’importe quel type t, ce qui briserait la sûreté du typage.70 CHAPITRE 5. TYPAGE
Γ � p → Γ�
Γ � e : t
Γ � e → Γ
(T-EXP)
Γ � e : t Γ� = Γ, global x : t
Γ � x = e → Γ� (T-VAR)
Γ � P
[ ] � p1 → Γ1 Γ1 � p2 → Γ2 ... Γn−1 � pn → Γn
� p1,...,pn
(PROG)
FIGURE 5.4 : Typage des phrases et programmes
5.6 Sûreté du typage
Comme nous l’évoquions au début de ce chapitre, le but du typage est de rejeter certains
programmes afin de ne garder que ceux qui ne provoquent pas un certain type d’erreurs à
l’exécution.
Dans la suite, nous donnons des propriétés que respectent tous les programmes bien
typés. Il est traditionnel de rappeler l’adage de Robin Milner :
Well-typed programs don’t go wrong.
To go wrong reste bien sûr à définir ! Cette sûreté du typage repose sur deux théorèmes :
• progrès : si un terme est bien typé, il y a toujours une règle d’évaluation qui s’applique.
• préservation (ou subject reduction) : l’évaluation transforme un terme bien typé en un
terme du même type.
5.7 Typage des valeurs
Puisque nous allons manipuler les propriétés statiques et dynamiques des programmes,
nous allons avoir à traiter des environnements de typage Γ et des états mémoires m. La première
chose à faire est donc d’établir une correspondance entre ces deux mondes.
Étant donné un état mémoire m, on associe un type de valeur τ aux valeurs v. Cela est fait
sous la forme d’un jugement m � v : τ.
Ces types de valeurs ne sont pas exactement les mêmes que les types statiques. Pour les
calculer, on n’a pas accès au code du programme, seulement à ses données. Il est par exemple
possible de reconnaître le type des constantes, mais pas celui des fonctions. Celles-ci sont en
fait le seul cas qu’il est impossible de déterminer à l’exécution. On le remplace donc par un
cas plus simple où seul l’arité est conservée.
Remarque Le fait d’effacer les types à l’exécution est un choix permettant d’alléger les valeurs
en mémoire. Il serait aussi possible de conserver les types complets à l’exécution, afin
de permettre une introspection dynamique des valeurs, mais cela éloignerait le langage de C.
Le cas des références (règle S-PTR) utilise le typage des valeurs gauches, codéfini par :
m �Φ ϕ : τ
def
== m � m[ϕ]Φ : τ5.7. TYPAGE DES VALEURS 71
Les règles de définition du typage des valeurs sont données dans la figure 5.5. On rappelle
que Φ est la lentille indexée définie page 52.
Type
de valeur
τ ::= INT Entier
| FLOAT Flottant
| UNIT Unité
| τ ∗ Pointeur
| τ[ ] Tableau
| {l1 : τ1;...;ln : τn} Structure
| FUNn Fonction
FIGURE 5.5 : Types de valeurs
Les règles sont détaillées dans la figure 5.6 : les types des constantes sont simples à retrouver
car il y a assez d’information en mémoire. Pour les références, ce qui peut être déréférencé
en une valeur de type τ est un τ ∗. Le typage des valeurs composées se fait en profondeur. En-
fin, la seule information restant à l’exécution sur les fonctions est son arité.
m � v : τ
m � n : INT
(S-INT)
m � d : FLOAT
(S-FLOAT)
m � ( ) : UNIT
(S-UNIT)
m � NULL : τ ∗ (S-NULL)
m �Φ ϕ : τ
m � &� ϕ : τ ∗ (S-PTR)
∀i ∈ [1;n].m � vi : τ
m � [v�1;...; vn] : τ[ ]
(S-ARRAY)
∀i ∈ [1;n].m � vi : τi
m � {l1 : v�1;...;ln : vn} : {l1 : τ1;...;ln : τn}
(S-STRUCT)
m � fun(x1,...,xn){i} : FUNn
(S-FUN)
FIGURE 5.6 : Règles de typage des valeurs
La prochaine étape est de définir une relation de compatibilité entre les types de valeurs
τ et statiques t. Nous noterons ceci sous la forme d’un jugement τ�t. Les règles sont décrites
dans la figure 5.7, la règle importante étant COMP-FUN. Notons qu’on garde le même nom
pour les types de base, et que par exemple INT peut être vu soit comme un type statique,
soit comme un type de valeur. Il y a donc un abus de notation dans la règle COMP-GROUND :
quand on note INT�INT, le premier désigne le type des valeurs à l’exécution, et le second le
type statique.
On définit enfin la notion d’état mémoire bien typé. On dit qu’un état mémoire m est bien
typé sous un environnement Γ, ce que l’on note Γ � m, si le type des valeurs à l’exécution
présent dans m est « compatible » avec les types présents dans Γ.
Cela se fait par induction sur la forme de Γ et m. Fonctionnellement, cela implique que
les accès à la mémoire retournent des valeurs en accord avec le type statique (lemme 5.6). Les72 CHAPITRE 5. TYPAGE
τ�t
t ∈ {INT, FLOAT, UNIT}
t �t
(COMP-GROUND)
τ�t
τ ∗�t ∗ (COMP-PTR)
τ�t
τ[ ]�t[ ]
(COMP-ARRAY)
∀i ∈ [1;n].τi �ti
{l1 : τ1;...;ln : τn}�{l1 : t1;...;ln : tn}
(COMP-STRUCT)
FUNn �(t1,...,tn) → t
(COMP-FUN)
FIGURE 5.7 : Compatibilité entre types de valeurs et statiques
[ ] � ([ ], [ ])
(M-EMPTY)
Γ � (s, g ) (s, g ) � v : τ τ�t
Γ, global x : t � (s, ((x �→ v) :: g ))
(M-GLOBAL)
Γ � m Γ = (ΓG ,ΓL) Γ� = (ΓG , [x1 : t1,...,xn : tn,R : t])
m � v1 : τ1 τ1 �t1 ... m � vn : τn τn �tn
Γ� � Push(m, ((x1 �→ v1),..., (xn �→ vn)))
(M-PUSH)
Γ = (ΓG ,ΓL) Γ � m
m� = Push(m, ((x1 �→ v1),..., (xn �→ vn))) (ΓG , [x1 : t1,...,xn : tn,R : t]) � m�
Γ � Cleanup(Pop(m�
))
(M-POP)
Γ � m m � v : τ τ�t x ∉ Γ
Γ,local x : t � Extend(m,x �→ v)
(M-DECL)
Γ,local x : t � m x ∉ Γ
Γ � CleanVar(m − x,x)
(M-DECLCLEAN)
Γ � m Γ � ϕ : t m � v : τ τ�t m� = m[ϕ ← v]
Γ � m� (M-WRITE)
FIGURE 5.8 : Compatibilité entre états mémoire et environnements de typage
règles définissant cette relation sont données dans la figure 5.8.
5.8 Propriétés du typage
On commence par énoncer quelques lemmes utiles dans la démonstration de ces théorèmes.
Les démonstrations des lemmes 5.1 et 5.2 sont des analyses de cas laborieuses et sans
difficulté ; dans ce cas on n’en donne que des esquisses.
Lemme 5.1 (Inversion). À partir d’un jugement de typage, on peut en déduire des informations
sur les types de ses sous-expressions.
• Constantes
• si Γ � n : t, alors t = INT
• si Γ � d : t, alors t = FLOAT
• si Γ � NULL : t, alors ∃t�
,t = t�
∗5.8. PROPRIÉTÉS DU TYPAGE 73
• si Γ � ( ) : t, alors t = UNIT
• Références mémoire :
• si Γ � (x) : t, alors x : t ∈ ΓG
• si Γ � (n, x) : t, alors x : t ∈ ΓL
• si Γ � l v[e] : t, alors Γ � l v : t[ ] et Γ � e : INT
• si Γ � l v.lS : t, alors Γ � l v : S
• Opérations :
• si Γ � � e : t, alors on est dans un des cas suivants :
• � ∈ {+,−,∼,!}, t = INT, Γ � e : INT
• � ∈ {+.,−.}, t = FLOAT, Γ � e : FLOAT
• si Γ � e1 � e2 : t, un des cas suivants se présente :
• � ∈ {+,−,×,/,&,|,^,&&,||,�,�,≤,≥,<,>}, Γ � e1 : INT, Γ � e2 : INT, t = INT
• � ∈ {+.,−.,×.,/.,≤ .,≥ .,< .,> .}, Γ � e1 : FLOAT, Γ � e2 : FLOAT, t = FLOAT
• � ∈ {=,�=}, Γ � e1 : t�
, Γ � e2 : t�
, EQ(t�
), t = INT
• � ∈ {≤,≥,<,>}, t = INT, Γ � e1 : t�
, Γ � e2 : t�
, t� ∈ {INT, FLOAT}
• � ∈ {+p,−p}, ∃t�
,t = t�
∗, Γ � e1 : t�
∗, Γ � e2 : INT
• Appel de fonction : si Γ � e(e1,...,en) : t, il existe (t1,...,tn) tels que :
�
Γ � e : (t1,...,tn) → t
∀i ∈ [1;n],Γ � ei : ti
• Fonction : si (ΓG ,ΓL) � fun(a1,...,an){i} : t, alors il existe (t1,...,tn) et t� tels que :
�
t = (t1,...,tn) → t�
(ΓG , [a1 : t1,...,an : tn,R : t�
]) � i
• Si Γ � ∗e : t, alors Γ � e : t∗.
• Si Γ � l v ← e : t, alors Γ � l v : t et Γ � e : t.
• Si Γ � & l v : t, alors il existe t� tel que Γ � l v : t� et t = t� ∗.
• Instructions :
• Si Γ � i1;i2, alors Γ � i1 et Γ � i2.
• Si Γ � e, alors il existe t tel que Γ � e : t.
• Si Γ � DECL x = e IN{i}, alors il existe t tel que Γ � e : t et Γ,local x : t � i.
• Si Γ � IF(e){it }ELSE{if }, alors Γ � e : INT, Γ � it et Γ � if .
• Si Γ � WHILE(e){i}, alors Γ � e : INT et Γ � i.
• Si Γ � RETURN(e), alors il existe t tel que Γ � e : t et Γ � R : t.
Démonstration (esquisse). Pour chaque forme de jugement de typage, on liste les règles qui
peuvent amener à cette conclusion.
Il est aussi possible de réaliser l’opération inverse : à partir du type d’une valeur, on peut
déterminer sa forme syntaxique. C’est bien sûr uniquement possible pour les valeurs, pas
pour n’importe quelle expression (par exemple l’expression x (variable) peut avoir n’importe
quel type t dans le contexte Γ = x : t).74 CHAPITRE 5. TYPAGE
Lemme 5.2 (Formes canoniques). Il est possible de déterminer la forme syntaxique d’une valeur
étant donné son type, comme décrit dans le tableau suivant. Par exemple, d’après la première
ligne, si Γ � v : INT, alors v est de la forme n (cf. � figure 4.7, page 44 pour la définition des
valeurs).
Type de v Forme de v
INT n
FLOAT d
UNIT ( )
t∗ &� ϕ ou NULL
t[ ] [v1;...; vn]
{l1 : t1;...;ln : tn} {l1 : v1;...;ln : vn}
(t1,...,tn) → t fun(a1,...,an){i}
Démonstration (esquisse). On procède comme pour le lemme d’inversion : pour chaque forme
syntaxique, on fait l’inventaire des règles pouvant arriver à cette dérivation.
Lemme 5.3 (Représentabilité). On définit un opérateur de représentation d’un type statique à
l’exécution :
Repr(INT) = INT
Repr(FLOAT) = FLOAT
Repr(UNIT) = UNIT
Repr(t�
∗) = Repr(t�
)∗
Repr(t�
[ ]) = Repr(t�
)[ ]
Repr({l1 : t1;...;ln : tn}) = {l1 : Repr(t1);...;ln : Repr(tn)}
Repr((t1,...,tn) → t) = FUNn
Supposons que Γ � v : t et Γ � m. On pose τ = Repr(t). Alors m � v : τ et τ�t.
Démonstration. On procède par induction sur la forme de t.
• INT : D’après le lemme des formes canoniques, v = n. On conclut avec S-INT et COMPGROUND.
• FLOAT : Idem avec v = d et S-FLOAT.
• UNIT : Idem avec v = ( ) et S-UNIT.
• t = t�
∗ : Soient τ� = Repr(t�
) et τ = τ� ∗. D’après le lemme des formes canoniques, deux
cas sont possibles :
• v = &� ϕ :
Par inversion (lemme 5.1), Γ � ϕ : t�
.
Puisque Γ � m et Γ � ϕ : t�
, on obtient par le lemme 5.6 que m[ϕ] est une valeur
telle que m � m[ϕ] : τ� où τ� �t�
. D’après S-PTR, m � &� ϕ : τ.
De plus par COMP-PTR τ�t.
• v = NULL :
Par induction, τ� �t�
. Alors par COMP-PTR, τ ∗�t ∗.
En outre, grâce à S-NULL, on obtient m � NULL : τ ∗.5.8. PROPRIÉTÉS DU TYPAGE 75
• t = t�
[ ] : Par le lemme des formes canoniques, v = [v�1;...; vn]. Par inversion on obtient
que ∀i,Γ � vi : t�
.
Soient τ� = Repr(t�
) et τ = τ� [ ].
Alors par induction ∀i,m � vi : τ� et τ� � t�
. De la première propriété il vient (via SARRAY)
m � v : τ, et de la seconde (via COMP-ARRAY) τ�t.
• {l1 : t1;...;ln : tn} : Par le lemme des formes canoniques, v = {l1 : v�1;...;ln : vn}. Et par
inversion, ∀i,Γ � vi : ti .
Soient τi = Repr(ti) et τ = {l1 : τ1;...;ln : τn}.
Alors par induction, ∀i,m � vi : τi et τi �ti .
On déduit de S-STRUCT que m � v : τ, et de COMP-STRUCT que τ�t.
• t = (t1,...,tn) → t� : Par formes canoniques, on a v = fun(a
�1,...,an){i}.
Soit τ = FUNn : par S-FUN on obtient que m � v : τ. On conclut d’autre part que τ� t
grâce à COMP-FUN.
Lemme 5.4 (Hauteur des chemins typés). Une valeur typée ne peut jamais pointer au dessus
du niveau courant de pile. (H (·) provient de la définition 4.6, page 47).
Si m � v : τ, alors H (v) ≤ |m|.
Démonstration. On procède par induction sur la forme de v.
c�: Alors H (v) = −1. Comme |m| ≥ 0, ce cas est établi.
f
�: Idem.
&� a : On distingue selon la forme de a. Si a = (x), c’est immédiat. Si a = (n,x), alors d’après
la forme de v, la dernière règle appliquée dans la dérivation de m � v : τ est S-PTR, et donc
m[(n,x)] est une valeur. D’après la définition de a, n ≤ |m|.
&� ϕ.l : On procède par induction sur v� = &� ϕ. Comme H (&� ϕ) ≤ |m| et H (&� ϕ.l) = H (&� ϕ),
on en déduit que H (&� ϕ.l) ≤ |m|.
&� ϕ[n] : Idem.
{l1 : v�1;...;ln : vn} : Par induction, ∀i ∈ [1;n],H (vi) ≤ |m|. Donc il en est de même pour leur
maximum, et H (v) ≤ |m|.
[v�1,..., vn] : Idem.
Lemme 5.5 (Accès à des variables bien typées). Soit Adr(ϕ) l’adresse de la variable qui apparaît
dans ϕ :
Adr(a) = a
Adr(ϕ.l) = Adr(ϕ)
Adr(ϕ[n]) = Adr(ϕ)76 CHAPITRE 5. TYPAGE
Alors, si Γ � m et Γ � ϕ : t, alors Adr(ϕ) est soit une variable globale (x) avec x ∈ dom(ΓG ),
soit une variable locale (|m|,x) du plus haut cadre de pile avec x ∈ dom(ΓL).
Démonstration. On procède par induction sur la forme de ϕ.
• Si ϕ = ϕ�
.lS, alors Adr(ϕ) = Adr(ϕ�
), et par inversion Γ � ϕ� : S. On conclut en appliquant
l’hypothèse de récurrence à ϕ�
.
• Si ϕ = ϕ�
[n], le cas est similaire.
• Si ϕ = a, alors Adr(ϕ) = a. Si a = (x), on a par inversion x : t ∈ ΓG . Si a = (n,x), alors par
inversion x : t ∈ ΓL. Il reste à montrer que n = |m|, ce qui peut se prouver par induction
sur la dérivation de Γ � m, en notant que dom(ΓL) coincide toujours avec le dernier
niveau de pile. Cette étape est ici admise.
Lemme 5.6 (Accès à une mémoire bien typée). Si Γ � m et Γ � ϕ : t, alors m[ϕ] est une valeur
v et m � v : τ où τ�t.
Démonstration. À partir du lemme 5.5, on prouve celui-ci par induction sur une dérivation
de Γ � m.
M-EMPTY : ΓG = ΓL = [ ], la prémisse Γ � ϕ : t est donc impossible à satisfaire.
M-GLOBAL : Soient ϕ tel que Γ, global x : t� � ϕ : t et m� = (s, ((x �→ v) :: g )). Alors la variable
référencée par ϕ est soit (x), soit (y) avec y ∈ dom(ΓG ), soit (|m|, y) avec y ∈ dom(ΓL).
Dans le premier cas, m�
[ϕ] = v, ce qui permet de conclure.
Dans les autres cas, m�
[ϕ] = m[ϕ], ce qui nous permet de conclure grâce à l’hypothèse
d’induction.
M-DECL : On part de Γ � ϕ : t�
. Alors Adr(ϕ) est soit la locale x, soit une autre variable locale,
soit une globale. Dans le premier cas, m�
[ϕ] = v et les prémisses nous permettent de conclure.
Dans tous les autres cas, m�
[ϕ] = m[ϕ] et on applique l’hypothèse d’induction.
M-DECLCLEAN : On suppose que Γ � ϕ : t� et m� = CleanVar(m − x,x). Alors Γ,local x :
t � ϕ : t� par affaiblissement. On peut donc appliquer l’hypothèse d’induction : m[ϕ] = v où
m � v : τ� avec τ� �t�
. On distingue alors selon la forme de v. Si v = &� ϕ� où Adr(ϕ�
) = (|m|,x),
alors m�
[ϕ] = NULL par l’opération CleanVar(·,·). Le type τ� étant un type pointeur par le
lemme 5.2, on peut conclure. Dans les autres cas m[ϕ] = m�
[ϕ] ce qui termine ce cas.
M-PUSH : On procède d’une manière similaire. ϕ peut faire référence soit à un des xi , auquel
cas la valeur vi convient, soit à une variable globale, auquel cas on applique l’hypothèse
de récurrence.
M-POP : On part de Γ � m�� où m�� = Cleanup(Pop(m�
)). Deux cas se produisent selon la
forme de Adr(ϕ).
• Soit Adr(ϕ) = (x) avec x ∈ dom(ΓG ), alors Γ� � ϕ : t où Γ� = (ΓG , [x1 : t1,...,xn : tn,R : t]).
On applique alors l’hypothèse de récurrence en partant du jugement Γ � m� : il vient
que m�
[ϕ] = v où m � v : τ� avec τ� � t�
. Comme H (v) ≤ |m�
| (lemme 5.4), deux cas
peuvent se produire :5.9. PROGRÈS ET PRÉSERVATION 77
• Si H (v) = |m�
|, alors m��[ϕ] = NULL et on a bien la compatibilité mémoire (l’argument
est similaire au cas DECL-CLEAN).
• Sinon, m��[ϕ] = m�
[ϕ] et on conclut directement.
• Soit Adr(ϕ) = (|m��|,x). On procède alors de la mème manière sauf qu’on invoque alors
le cas d’induction sur Γ � m.
M-WRITE : On part de Γ � m� où m� = m[ϕ ← v], et on suppose que Γ � ϕ� : t�
.
Si ϕ = ϕ� : alors il suffit d’appliquer GETPUT à la lentille Φ : m[ϕ ← m[ϕ]] = m, ce qui
donne directement la conclusion.
Si ϕ �= ϕ� : Γ � ϕ� : t� donc Adr(ϕ�
) est soit une locale soit une globale de m�
. Donc m�
[ϕ�
] =
m[ϕ�
] et on conclut grâce à l’hypothèse d’induction.
5.9 Progrès et préservation
Ces lemmes étant établis, on énonce maintenant le théorème de progrès. Contrairement
aux langages où tout est expression, il faut traiter séparément les trois constructions principales
de SAFESPEAK : les expressions, les valeurs gauches et les instructions. Celles-ci sont
mutuellement dépendantes car :
• la définition d’une fonction par un bloc est une expression ;
• une expression est un cas particulier d’instruction ;
• une valeur gauche peut convenir une expression en indice de tableau ;
• une valeur gauche est un cas particulier d’expression.
Théorème 5.1 (Progrès). Supposons que Γ � i. Soit m un état mémoire tel que Γ � m.
Alors l’un des cas suivants est vrai :
• i = PASS
• ∃v,i = RETURN(v)
• ∃(i�
,m�
),〈i,m〉 → 〈i�
,m�
〉
• ∃Ω ∈ {Ωd i v ,Ωar r ay ,Ωp t r },〈i,m〉 → Ω
� � �
Supposons que Γ � e : t. Soit m un état mémoire tel que Γ � m. Alors l’un des cas suivants
est vrai :
• ∃v �= Ω,e = v
• ∃(e�
,m�
),〈e,m〉 → 〈e�
,m�
〉
• ∃Ω ∈ {Ωd i v ,Ωar r ay ,Ωp t r },〈e,m〉 → Ω
� � �
Supposons que Γ � l v : t. Soit m un état mémoire tel que Γ � m.
Alors l’un des cas suivants est vrai :
• ∃ϕ,l v = ϕ
• ∃(l v�
,m�
),〈l v,m〉 → 〈l v�
,m�
〉
• ∃Ω ∈ {Ωd i v ,Ωar r ay ,Ωp t r },〈l v,m〉 → Ω
C’est-à-dire, soit :
• l’entité (instruction, expression ou valeur gauche) est complètement évaluée.78 CHAPITRE 5. TYPAGE
• un pas d’évaluation est possible.
• une erreur de division, tableau ou pointeur se produit.
La preuve du théorème 5.1 se trouve en annexe D.2.
Théorème 5.2 (Préservation). Soit Γ un environnement de typage et m un état mémoire tels
que Γ � m.
Alors :
• Si Γ � l v : t et 〈l v,m〉 → 〈ϕ,m�
〉, alors Γ � Cleanup(m�
) et m� �Φ ϕ : τ où τ�t.
• Si Γ � l v : t et 〈l v,m〉 → 〈l v�
,m�
〉, alors Γ � Cleanup(m�
) et Γ � l v� : t.
• Si Γ � e : t et 〈e,m〉 → 〈v,m�
〉, alors Γ � Cleanup(m�
) et m� � v : τ où τ�t.
• Si Γ � e : t et 〈e,m〉 → 〈e�
,m�
〉, alors Γ � Cleanup(m�
) et Γ � e� : t.
• Si Γ � i et 〈i,m〉 → 〈i�
,m�
〉, alors Γ � Cleanup(m�
) et Γ � i�
.
Autrement dit, si une construction est typable, alors un pas d’évaluation ne modifie pas son
type et préserve le typage de la mémoire.
Remarque Dans la formulation classique de ce théorème, on indique que Γ � m implique
Γ � m�
. Ici, la conclusion est moins forte en indiquant seulement que Γ � Cleanup(m�
). Cela
indique que la compatibilité mémoire est établie mais peut localement introduire des pointeurs
fous. En fait, comme une étape de Cleanup(·) est faite après chaque appel de fonction et
chaque déclaration, la propriété classique est vraie mais uniquement sur un plus grand pas
d’exécution.
La preuve de ce théorème se trouve en annexe D.3.
Cela établit qu’aucun terme ne reste « bloqué » parce qu’aucune règle ne s’applique, et
que la sémantique respecte le typage. En quelque sorte, les types sont un contrat entre les
expressions et les fonctions : si leur évaluation converge, alors une valeur du type inféré sera
produite.
Enfin, on donne une version de ces propriétés pour les phrases de programme.
Théorème 5.3 (Progrès pour les phrases). Soit Γ un environnement de typage, m un état mé-
moire et p une phrase de programme. Supposons que Γ � p → Γ� et Γ � m.
On suppose en outre que l’évaluation de p termine.
Alors ∃m�
.m � p → m�
.
Démonstration. Ici il n’y a pas de difficulté puisque la contrainte (forte) de terminaison se lit
〈e,m〉 → 〈v�
,m�
〉 où e est l’expression apparaissant dans p.
Selon la forme de p, il suffit alors d’appliquer la règle ET-EXP ou ET-VAR.
Théorème 5.4 (Préservation pour les phrases). On suppose que les trois propriétés suivantes
sont vérifiées :
Γ � m
Γ � p → Γ�
m � p → m�
Alors Γ� � m�
.
Démonstration. On distingue selon la dernière règle appliquée dans la dérivation de m �
p → m�
.5.9. PROGRÈS ET PRÉSERVATION 79
ET-EXP : La dernière règle appliquée pour dériver Γ � p → Γ� est donc T-EXP. D’après les
prémisses de ces deux règles, on a donc Γ � e : t et 〈e,m〉 → 〈v,m�
〉. Alors, d’après le théorème
de préservation, Γ � m�
.
ET-VAR : Ici, la dérivation de Γ � p → Γ� termine par T-VAR. D’après leurs prémisses, on a
donc : Γ � e : t, Γ� = Γ, global x : t, et m�� = (s, (x �→ v) :: g ) où (s, g ) = m� (on cherche à prouver
que � � m��).
En appliquant le théorème de préservation, on obtient que Γ � v : t et Γ � m�
. D’après le
lemme 5.3, il existe τ tel que m� � v : τ où τ�t. On peut alors appliquer M-GLOBAL qui nous
donne que � � m��.
Conclusion
En ajoutant un système de types statiques à SAFESPEAK, on peut calculer à la compilation
la forme des valeurs produites par chaque expression. Pour ce faire, on a défini un ensemble
de règles de typage (regroupées dans l’annexe C) à appliquer selon la forme de celle-ci.
Si on considère des programmes qui sont seulement syntaxiquement corrects, on ne peut
rien prédire sur leur exécution. Par exemple, fun(x){PASS}+1 est une expression correcte mais
pour laquelle il n’y a pas de règle d’évaluation qui s’applique. En ajoutant un système de
types, les propriétés de sûreté établies dans ce chapitre assurent que les termes peuvent être
évalués, et que les valeurs produites sont en accord avec les types donnés aux différentes parties
du programme. Cela permet surtout de s’assurer que les programmes ne peuvent provoquer
une erreur d’exécution que dans certains cas particuliers, comme les divisions ou les
accès aux tableaux.
À l’issue de ce chapitre, on a donc un langage impératif sain pour bâtir des analyses de
typage, ce que nous allons faire dans le chapitre suivant.C H A P I T R E
6
EXTENSIONS DE TYPAGE
Nous venons de définir un système de types sûrs dans le chapitre 5. Cela permet de mettre
en relation les types des expressions avec les valeurs qui leur seront associées. Cela permet
une forme d’analyse de flot : si peu de constructions permettent de créer des valeurs d’un
type t, alors toutes les valeurs de type t proviennent de ces « sources ».
On se propose ici d’enrichir le système de types de plusieurs extensions permettant d’explorer
cette idée, en ajoutant de la « signification » dans les types de données des programmes.
Ces extensions permettront de détecter des erreurs de programmation communes, appuyées
sur des exemples réels.
Cela revient à introduire une séparation entre le type des données et sa représentation,
c’est-à-dire définir un type abstrait. Dans un système d’exploitation, les pointeurs utilisateur
sont en fait des pointeurs classiques déguisés, pour lesquels on interdit l’opérateur de déré-
férencement.
Cette technique est en fait générique : on peut également l’appliquer à certains types
d’entiers. En C, il est commun d’utiliser des int pour tout et n’importe quoi : pour des entiers
bien sûr (au sens de Z), mais aussi comme identificateurs pour lesquels les opérations
usuelles comme l’addition n’ont pas de sens. Par exemple, sous Linux, l’opération d’ouverture
de fichier renvoie un entier, dit descripteur de fichier, qui identifie ce fichier pour ce processus.
Le langage autorise donc par exemple de multiplier entre eux deux descripteurs de
fichiers, mais le résultat n’a pas de raison a priori d’être un descripteur de fichier valide.
En n’offrant pas cette distinction, le langage C permet d’écrire du code qui peut s’exé-
cuter mais dont la sémantique n’est, quelque part, pas bien fondée. En effet, le système de
types de C est trop primitif pour pouvoir garantir une véritable isolation entre deux types
de même représentation : il n’y a pas de types abstraits. Certes, typedef permet d’introduire
un nouveau nom pour un type, mais ce n’est qu’un raccourci syntaxique. Le compilateur ne
peut en effet pas considérer un programme sans avoir la définition quasi-complète des types
qui y apparaissent. La seule exception concerne les pointeurs sur structures : si on ne fait
que les affecter, il n’est pas nécessaire de connaître la taille ni la disposition de la structure ;
donc la définition peut ne pas être visible. Cette technique, connue sous le nom de pointeurs
opaques, n’est pas applicable aux autres types.
En ajoutant une couche de typage, on interdit ces opérations à la compilation. Cela permet
deux choses : pour le code déjà écrit, de détecter et corriger les manipulations dangereuses
; et, pour le nouveau code, de s’assurer qu’il est correct. Par exemple, si on écrit un
éditeur de texte, on peut éviter de nombreuses erreurs de programmation en définissant un
type « indice de ligne » et un type « indice de colonne » incompatibles entre eux.
Un premier exemple permet de distinguer plusieurs utilisations des entiers, selon s’ils
8182 CHAPITRE 6. EXTENSIONS DE TYPAGE
sont utilisés comme entiers arithmétiques ou ensemble de bits. Cela permet de détecter une
erreur courante qui consiste à mélanger les opérateurs logiques et bit à bit.
Ensuite, on étend de manière indépendante le système de types, cette fois au niveau des
pointeurs. Plus précisément, dans le contexte des systèmes d’exploitation, on introduit une
différence entre les pointeurs dont la valeur est contrôlée par l’utilisateur et ceux dont elle ne
l’est pas.
6.1 Exemple préliminaire : les entiers utilisés comme bitmasks
Dans le langage C, les types de données décrivent uniquement l’agencement en mé-
moire des valeurs. Ils n’ont pas de signification plus sémantique permettant d’exprimer ce
que les données représentent. Par exemple, dans un programme manipulant des dates, on
sera amené à manipuler des numéros de mois et d’années, représentés par des types entiers.
Le langage C permet de définir des nouveaux types :
typedef int month_t;
typedef int year_t;
Cependant, rien ne distingue le nouveau type de l’ancien. Il ne s’agit que d’une aide à la
documentation. Dans cet exemple, month_t et year_t sont tous les deux des nouveaux noms
pour le type int ; donc ils sont en fait compatibles. Le compilateur ne peut donc pas détecter
qu’on utilise un numéro de mois là où un numéro d’année était attendu (ou vice versa).
Cet idiome est commun en C. On manipule notamment certaines données abstraites par
des clés entières, et un typedef particulier permet de désigner celles-ci. Par exemple sous
Linux, les numéros de processus sont des indices dans la table de processus interne au noyau,
et on y accède par une valeur de type pid_t. De même, les utilisateurs sont représentés par
un nombre entier du type uid_t.
Un autre idiome est répandu : l’utilisation d’entiers comme représentation d’un ensemble
de booléens. En effet, un nombre a = �N−1
i=0 ai 2i peut s’interpréter comme la liste d’indices
de ses bits égaux à 1 : {i ∈ [0;N − 1]/ai = 1}. Un entier de 32 bits peut donc représenter une
combinaison de 32 options indépendantes.
C’est de cette manière que fonctionne l’interface qui permet d’ouvrir un fichier sous Unix
(figure 6.1). Le paramètre flags est un entier qui encode les options liées à l’ouverture du
fichier. On précise son mode (lecture, écriture ou les deux) par les bits 1 et 2 ; s’il faut créer
le fichier ou non s’il n’existe pas par le bit 7 ; si dans ce cas il doit être effacé par le bit 8,
etc. On obtient le paramètre complet en réalisant un « ou » bit à bit entre des constantes. Le
paramètre mode encode de la même manière les permissions que doit avoir le fichier créé, le
cas échéant (mode_t désigne en fait unsigned int).
int open(const char *pathname, int flags, mode_t mode);
int creat(const char *pathname, mode_t mode);
FIGURE 6.1 : Interface permettant d’ouvrir un fichier sous Unix
Ces fonctions retournent un entier, qui est un descripteur de fichier. Il correspond à un
indice numérique dans une table interne au processus. Par exemple, 0 désigne son entrée
standard, 1 sa sortie standard, et 2 son flux d’erreur standard.
On identifie donc au moins trois utilisations du type int :6.1. EXEMPLE PRÉLIMINAIRE : LES ENTIERS UTILISÉS COMME BITMASKS 83
• entier : c’est l’utilisation classique pour représenter des valeurs numériques. Toutes les
opérations sont possibles.
• bitmask : on utilise un entier comme ensemble de bits. Seules les opérations bit à bit
ont du sens.
• entier opaque : on utilise un entier de manière purement abstraite. C’est l’exemple des
descripteurs de fichier.
Ces utilisations du type n’ont rien à voir ; il faudrait donc empêcher d’utiliser un descripteur
de fichier comme un mode, et vice-versa. De même, aucun opérateur n’a de sens sur
les descripteurs de fichier, mais l’opérateur | du « ou » bit à bit doit rester possible pour les
modes.
On décrit ici une technique de typage pour détecter et interdire ces mauvaises utilisations
en proposant une version « bien typée » de la fonction open. Plus précisément, on donne à ses
deuxième et troisième arguments (respectivement flags et mode) le nouveau type BITS qui
correspond aux entiers utilisés comme bitmasks. Le type de retour n’est pas modifié (il reste
INT), mais on décrit comment il est possible de rendre ce type opaque.
6.1.1 Modifications
On commence par ajouter deux types : d’une part BITS bien sûr, mais également CHAR qui
apparaît dans les chaînes de caractères. On ne spécifie pas plus ce dernier mais on suppose
qu’il existe des littéraux de chaînes qui retournent un pointeur vers le premier élément d’un
tableaux de caractères. Pour rester compatible avec C, on suppose qu’un caractère nul ’\0’
est inséré à la fin de la chaîne. On ajoute ces chaînes uniquement dans le but de pouvoir
représenter les noms de fichiers.
Au niveau des valeurs, les entiers utilisés comme bitmasks sont représentés par des valeurs
entières classiques n�. En particulier, on n’ajoute pas de nouveau type sémantique, mais
on ajoute une règle de compatibilité entre le type de valeurs INT et le type statique BITS
(cela signifie qu’une valeur de type BITS est représentée par un INT en mémoire, figure 6.2).
Par ailleurs, on change le type des « constructeurs » (O_RDONLY, O_RDWR, O_APPEND, . . . ) et du
« consommateur » open (figure 6.3).
Type t ::= ...
| CHAR Caractère
| BITS Entier utilisé comme bitmask
INT�BITS
(COMP-BITS)
FIGURE 6.2 : Ajouts liés aux entiers utilisés comme bitmasks
Pour que les opérations bit à bit puissent s’appliquer aux bitmasks, on ajoute aux règles
s’appliquant à INT les règles suivantes. Cela revient à permettre plusieurs types pour l’opérateur
∼, mais la sémantique d’exécution est la même quel que soit le type, car BITS et INT sont
représentés de la même manière.
� ∈ { | , & , ^ } Γ � e1 : BITS Γ � e2 : BITS
Γ � e1 � e2 : BITS
(OP-BITS)
Γ � e : BITS
Γ �∼ e : BITS
(NOT-BITS)84 CHAPITRE 6. EXTENSIONS DE TYPAGE
[ ] � O_RDONLY : BITS
[ ] � O_RDWR : BITS
[ ] � O_APPEND : BITS
[ ] � open : (CHAR ∗, BITS) → INT
FIGURE 6.3 : Nouvelles valeurs liées aux bitmasks
Il reste à permettre d’utiliser les bitmasks dans les contextes où on attend un entier. Par
exemple, pour écrire IF(x & 0x80){...}ELSE{...} (test du bit numéro 7). On veut donc exprimer
que « un BITS est un INT ». Cette relation entre différents types d’entier correspond à un cas
particulier de sous-typage.
On ajoute la règle de subsomption suivante. Elle permet d’utiliser une expression de type
BITS là où une expression de type INT est attendue.
Γ � e : BITS
Γ � e : INT
(SUB-BITSINT)
Cela modifie légèrement l’implantation de l’inférence de types. Le type d’une expression
utilisée comme opérande de l’opérateur + n’est donc pas a priori de type INT, mais BITS ou
INT. Cela implique aussi qu’on peut additionner un BITS et un INT pour obtenir un INT. Les
expressions de la forme fun(x, y){RETURN(x|y)} peuvent donc accepter plusieurs types. Pour
l’inférence, cela correspondra à une inconnue de type. Si celle-ci n’est pas résolue à la fin de
l’inférence (par exemple si cette fonction n’est pas appelée), une erreur est levée. C’est une
limitation du monomorphisme.
Ainsi, si Γ � e : BITS, on a par exemple Γ � ! e : INT. On rappelle que la règle permettant de
typer ! est inchangée et reste la suivante :
� ∈ {∼,!} Γ � e : INT
Γ � � e : INT
(UNOP-NOT)
6.1.2 Exemple : ! x & y
Les nombreux opérateurs de C (repris en SAFESPEAK) posent plusieurs problèmes :
• il sont nombreux et il est facile de confondre && avec &, ou ! avec ∼ ;
• il y a un opérateur « ou exclusif » bit à bit (^) mais pas d’équivalent logique ;
• la priorité des opérateurs semble parfois arbitraire. Par exemple, les opérateurs de dé-
calage sont plus prioritaires que les additions, donc x � 2 + 1 est interprété comme
(x � 2)+1.
Le premier et le dernier point permettent d’expliquer une erreur courante : celle qui
consiste à écrire ! x & y au lieu de ! (x & y).
En effet, la première expression est équivalente à (! x) & y. Comme ! x vaut 0 ou 1, l’expression
résultante vaut y & 1 si x = 0, ou 0 sinon. Il s’agit probablement d’une erreur de
programmation. L’alternative ! (x & y) a plus de sens : elle vaut 0 si x et y ont un bit en commun,
1 sinon.6.2. ANALYSE DE PROVENANCE DE POINTEURS 85
On vérifie enfin que la première n’est pas bien typée alors que la seconde l’est. Dans les
deux cas suivants on se place dans un environnement Γ comportant deux variables globales
x et y de type BITS. Alors (! x)&y n’est pas bien typée. En effet, Γ � ! x : INT et la seule règle
qui s’applique à l’opérateur & ne peut pas s’appliquer. En revanche la seconde est bien typée
(figure 6.4).
Γ = ([x �→ BITS, y �→ BITS], [ ])
Γ � x : BITS Γ � y : BITS
Γ � x & y : BITS
(OP-BITS)
Γ � x & y : INT
(SUB-BITSINT)
Γ � ! (x & y) : INT
(UNOP-NOT)
FIGURE 6.4 : Dérivation montrant que ! (x & y) est bien typée
Cet exemple préliminaire permet de voir en quoi SAFESPEAK est adapté à des analyses
de typage légères. Puisque le typage est sûr, on en déduit que les valeurs d’un certain type
ne peuvent être créées que par un certain nombre de constructeurs. Par exemple ici les bitmasks
ne proviennent que de combinaisons de constantes. C’est précisément cette idée de
détection de source qui est au cœur de l’analyse suivante.
6.2 Analyse de provenance de pointeurs
Jusqu’ici SAFESPEAK est un langage impératif généraliste, ne prenant pas en compte les
spécificités de l’adressage utilisé dans les systèmes d’exploitation.
Dans cette section, on commence par l’étendre en ajoutant des constructions modélisant
les variables présentes dans l’espace utilisateur (cf. chapitre 2). Pour accéder à celles-ci, on
ajoute un opérateur de déréférencement sûr qui vérifie à l’exécution que l’invariant suivant
est respecté :
Les pointeurs dont la valeur est contrôlée par l’utilisateur pointent vers l’espace
utilisateur.
La terminologie mérite d’être détaillée :
Un pointeur contrôlé par l’utilisateur, ou pointeur utilisateur, est une référence mémoire
dont la valeur est modifiable par le code utilisateur (opposé au code noyau, que nous analysons
ici). Ceci correspond à des données provenant de l’extérieur du système vérifié. C’est
une propriété statique, qui peut être déterminée à la compilation à partir de considérations
syntaxiques. Par exemple, l’adresse d’une variable locale au sein de code noyau est toujours
considérée comme étant contrôlée par le noyau.
Un pointeur pointant vers l’espace utilisateur fait référence à une variable allouée en espace
utilisateur. Cela veut dire qu’y accéder ne risque pas de mettre en péril l’isolation du
noyau en faisant fuiter des informations confidentielles ou en déjouant son intégrité. Cette
propriété est dynamique : un pointeur utilisateur peut a priori pointer vers l’espace utilisateur,
ou non.
Pour prouver que l’invariant précédent est bien respecté, on procède en plusieurs étapes.
Tout d’abord, on définit une nouvelle erreur Ωsec (pour « sécurité »), déclenchée lorsqu’un
pointeur contrôlé par l’utilisateur et pointant vers le noyau est déréférencé (le cas que86 CHAPITRE 6. EXTENSIONS DE TYPAGE
l’on cherche à éviter). Il est important de noter que ce cas d’erreur est « virtuel », c’est-à-dire
qu’on l’ajoute à la sémantique pour pouvoir le détecter facilement comme un cas d’erreur,
mais, dans une sémantique de plus bas niveau, comme en C, l’erreur ne serait pas déclenchée.
D’un point de vue opérationnel, cela équivaut à ajouter un test dynamique à chaque
déréférencement, ce qui est sûr mais se paye en performances. Ajouter ce cas d’erreur virtuel
dans la sémantique d’évaluation permet de transformer un problème de sécurité (empêcher
les fuites d’information) en problème de sûreté (empêcher les erreurs à l’exécution).
Ensuite, on montre qu’avec cet ajout, si on étend naïvement le système de types en donnant
le même type aux pointeurs contrôlés par l’utilisateur et le noyau, le théorème de progrès
(5.1) n’est plus valable. Cela signifie que le système de types classique présenté dans le
chapitre 5 ne suffit pas à capturer les propriétés de sécurité que nous voulons interdire.
L’étape suivante est d’étendre, à son tour, le système de types de SAFESPEAK en distinguant
les types des pointeurs contrôlés par l’utilisateur des pointeurs contrôlés par le noyau.
Puisqu’on veut interdire le déréférencement des premiers par l’opérateur *, on introduit les
constructions copy_from_user et copy_to_user qui réaliseront le déréférencement sûr de ces
pointeurs.
Enfin, une fois ces modifications faites, on prouve que les propriétés de progrès et de
préservation sont rétablies.
6.2.1 Extensions noyau pour SAFESPEAK
On ajoute à SAFESPEAK la notion de valeur provenant de l’espace utilisateur. Pour marquer
la séparation entre les deux espaces d’adressage, on ajoute une construction ϕ ::= ♦� ϕ�
.
Le chemin interne ϕ� désigne une variable classique (un pointeur noyau) et l’opérateur ♦� ·
permet de l’interpréter comme un pointeur vers l’espace utilisateur. En quelque sorte, on ne
classifie pas les valeurs selon la variable pointée mais selon la construction du pointeur.
Remarquons qu’on n’introduit pas de sous-typage : les pointeurs noyau ne peuvent être
utilisés qu’en tant que pointeurs noyau, et les pointeurs utilisateur qu’en tant que pointeurs
utilisateur.
En plus du déréférencement par * (qui devra donc renvoyer Ωsec pour les valeurs de la
forme ♦� ϕ�
), il faut aussi ajouter des constructions de lecture et d’écriture à travers les pointeurs
utilisateur. Ceci sera fait sous forme de deux fonctions, copy_from_user et
copy_to_user. Celles-ci prennent deux pointeurs en paramètre et renvoient un booléen indiquant
si la copie a pu être faite (si le paramètre contrôlé par l’utilisateur pointe en espace
noyau, les fonctions ne font pas la copie et signalent l’erreur).
Illustrons ceci par un exemple. Imaginons un appel système fictif qui renvoie la version
du noyau, en remplissant par pointeur une structure contenant les champs entiers major,
minor et patch (un équivalent dans Linux est l’appel système uname()). Celui-ci peut être
alors écrit comme dans la figure 6.5. Une fois la structure noyau v remplie, il faut la copier
vers l’espace utilisateur. La fonction copy_to_user va réaliser cette copie (de la même manière
qu’avec un memcpy()), mais après avoir testé dynamiquement que p pointe en espace
utilisateur (dans le cas contraire, la copie n’est pas faite).
On peut remarquer que, contrairement aux fonctions présentes dans le noyau Linux, les
fonctions copy_from_user et copy_to_user n’ont pas de paramètre indiquant la taille à copier.
Cela est dû au fait que le modèle mémoire de SAFESPEAK est de plus haut niveau. L’information
de taille est déjà présente dans chaque valeur.
Une autre remarque à faire est qu’il n’y a pas de manière de copier des données de l’espace
utilisateur vers l’espace utilisateur. Il est nécessaire de passer par l’espace noyau. La
raison est que, puisqu’il faut réaliser deux tests dynamiques, les erreurs peuvent arriver à ces6.2. ANALYSE DE PROVENANCE DE POINTEURS 87
sys_getver = fun(p){
DECL v = { major : 3;minor : 14;patch : 15 } IN
copy_to_user(p,&v)
}
FIGURE 6.5 : Implantation d’un appel système qui remplit une structure par pointeur
Expressions e ::= ...
| ♦ l v Adresse utilisateur
| copy_from_user(ed ,es) Lecture depuis l’espace utilisateur
| copy_to_user(ed ,es) Écriture vers l’espace utilisateur
Contextes C ::= ...
| ♦ C
| copy_from_user(C,e)
| copy_from_user(v,C)
| copy_to_user(C,e)
| copy_to_user(v,C)
Chemins ϕ ::= ...
| ♦� ϕ Pointeur utilisateur
Erreurs Ω ::= ...
| Ωsec Erreur de sécurité
FIGURE 6.6 : Ajouts liés aux pointeurs utilisateur (par rapport à l’interprète du chapitre 4)
deux endroits. Plutôt que de proposer un opérateur qui réalise cette copie, on laisse le programmeur
faire les deux copies manuellement.
On commence donc par ajouter aux instructions des constructions copy_to_user(·,·) et
copy_from_user(·,·) de copie sûre. copy_from_user(pk ,pu) copie la valeur pointée par pu
(qui se trouve en espace utilisateur) à l’emplacement mémoire pointé par pk (en espace
noyau). copy_to_user(pu,pk ) réalise l’opération inverse, en copiant la valeur pointée par pk
(en espace noyau) à l’emplacement mémoire pointé par pu (en espace utilisateur).
Afin de leur donner une sémantique, il faut étendre l’ensemble des valeurs pointeur ϕ aux
constructions de la forme ♦� ϕ�
. Pour créer des termes s’évaluant en de telles valeurs, il faut
une construction syntaxique ♦ e telle que, si e s’évalue en &� ϕ, ♦ e s’évalue en &� ♦� ϕ. Cela
demande 2 autres ajouts : un nouveau contexte d’évaluation ♦ C et une règle d’évaluation.
Enfin, on ajoute une nouvelle erreur Ωsec à déclencher lorsqu’on déréférence directement un
pointeur utilisateur. Ces étapes sont résumées dans la figure 6.6.
En résumé, on a deux constructions pour créer des pointeurs à partir d’une valeur gauche :88 CHAPITRE 6. EXTENSIONS DE TYPAGE
& · crée un pointeur noyau, et ♦ · crée un pointeur utilisateur. Seule la première est faite pour
être utilisée dans le code à analyser. La seconde sert uniquement à modéliser les points d’entrée
du noyau. Par exemple, la fonction sys_getver de la figure 6.5 peut être appelée par un
utilisateur de la manière décrite dans la figure 6.7.
DECL v = { major : 0;minor : 0;patch : 0 } IN
sys_getver(♦ v)
FIGURE 6.7 : Appel de la fonction sys_getver de la figure 6.5
6.2.2 Extensions sémantiques
En ce qui concerne l’évaluation des expressions ♦ ·, on ajoute la règle suivante :
〈♦ ϕ,m〉 → 〈& ( � ♦� ϕ),m〉
(PHI-USER)
Dans & ( � ♦� ϕ), l’opérateur &� · indique que la valeur créée est une référence mémoire. Cette
référence mémoire, ♦� ϕ, est contrôlée par l’utilisateur. C’est ce qu’indique le constructeur ♦� ·
Cette règle semble asymétrique. C’est lié au fait qu’habituellement, les valeurs pointeurs
(de la forme &� Φ) sont crées en utilisant la règle CTX avec l’opérateur &. Ici une expression
crée une valeur pointeur, il faut donc y insérer un &. En effet, � ♦� · n’est qu’une transformation
entre chemins, pas une manière de construire une valeur à partir d’un chemin comme &. �
Ensuite, il est nécessaire d’adapter les règles d’accès à la mémoire pour déclencher une
erreur Ωsec en cas de déréférencement d’un pointeur utilisateur. Les accès mémoire en lecture
proviennent de la règle EXP-LV et ceux en lecture, de la règle EXP-SET, rappellées ici :
〈ϕ,m〉 → 〈m[ϕ]Φ,m〉
(EXP-LV )
〈ϕ ← v,m〉 → 〈v,m[ϕ ← v]Φ〉
(EXP-SET)
Les accès à la mémoire sont en effet faits par le biais de la lentille Φ. Il suffit donc d’adapter
sa définition (page 52) de celle-ci en rajoutant les cas suivant :
getΦ(♦� ϕ) = Ωsec
putΦ(♦� ϕ, v) = Ωsec
Enfin, il est nécessaire de donner une sémantique aux fonctions copy_from_user et
copy_to_user. L’idée est que celles-ci testent dynamiquement la valeur du paramètre
contrôlé par l’utilisateur afin de vérifier que celui-ci pointe vers l’espace utilisateur (c’est-
à-dire, qu’il est de la forme ♦� ϕ).
Deux cas peuvent se produire. Soit la partie à vérifier a la forme ♦� ϕ�
, soit non (et dans ce
cas �ϕ�
,ϕ = ♦� ϕ�
). Dans le premier cas (règles USER-*-OK), alors la copie est faite et l’opération
de copie retourne la valeur entière 0. Dans le second (règles USER-*-ERR), aucune copie
n’est faite et la valeur −1 est retournée. Ce comportement est calé sur celui des fonctions
copy_{from,to}_user du noyau Linux : en cas de succès elles renvoient 0, et en cas d’erreur
-EFAULT (= −14).6.2. ANALYSE DE PROVENANCE DE POINTEURS 89
v = m[ϕs]Φ m� = m[ϕd ← v]Φ
〈copy_from_user(&� ϕd ,& ( � ♦� ϕs)),m〉 → 〈0,m�
〉
(USER-GET-OK)
� ϕs.ϕ = ♦� ϕs
〈copy_from_user(&� ϕd ,&� ϕ),m〉 → 〈−14,m〉
(USER-GET-ERR)
v = m[ϕs]Φ m� = m[ϕd ← v]Φ
〈copy_to_user(& ( � ♦� ϕd ),&� ϕs),m〉 → 〈0,m�
〉
(USER-PUT-OK)
� ϕd .ϕ = ♦� ϕd
〈copy_to_user(&� ϕ,&� ϕs),m〉 → 〈−14,m〉
(USER-PUT-ERR)
Ces règles sont à appliquer en priorité de la règle d’appel de fonction classique, puisqu’il
s’agit d’élements de syntaxe différents. En effet ces « fonctions » ne sont pas implantables
directement en SAFESPEAK, puisqu’il n’y a pas par exemple d’opérateur permettant d’extraire
ϕ depuis une valeur ♦� ϕ. L’opération en « boîte noire » de ces deux fonctions permet d’assurer
que l’accès à l’espace utilisateur est toujours couplé à un test dynamique.
6.2.3 Insuffisance des types simples
Étant donné SAFESPEAK augmenté de cette extension sémantique, on peut étendre naï-
vement le système de types avec la règle suivante :
Γ � l v : t
Γ � ♦ l v : t ∗ (ADDR-USER-IGNORE)
Cette règle est compatible avec l’extension, sauf qu’elle introduit des termes qui sont bien
typables mais dont l’évaluation provoque une erreur Ωsec ∉ {Ωd i v ,Ωar r ay ,Ωp t r }, violant ainsi
le théorème 5.1. Posons :
e = ∗♦ x
Γ = x : INT
m = ([[x �→ 0]], [ ])
Les hypothèses du théorème de progrès sont bien vérifiées, mais cependant la conclusion
n’est pas vraie :
• On a bien Γ � m. En effet :
[ ] � ([ ], [ ])
(M-EMPTY)
[ ] � 0 : INT INT�INT
x : INT � ([[x �→ 0]], [ ])
(M-PUSH)
• e est bien typée sous Γ :90 CHAPITRE 6. EXTENSIONS DE TYPAGE
x : INT ∈ Γ
Γ � x : INT
(LV-VAR)
Γ � &x : INT∗ (LV-DEREF)
Γ � ♦ x : INT∗ (ADDR-USER-IGNORE)
Γ � ∗♦ x : INT
(LV-DEREF)
• L’évaluation de e sous m provoque une erreur différente de Ωd i v , Ωar r ay , ou Ωp t r :
m[♦� x] = Ωsec
〈∗♦ x,m〉 → 〈Ωsec ,m〉
(EXP-LV )
〈Ωsec ,m〉 → Ωsec
(EVAL-ERR)
〈e,m〉 → Ωsec
Cela montre que le typage n’apporte plus de garantie de sûreté sur l’exécution : le système
de types naïvement étendu par une règle comme ADDR-USER-IGNORE n’est pas en adéquation
avec les extensions présentées dans la section 6.2.1. Il faut donc raffiner les règles de
typage pour interdire ce cas.
6.2.4 Extensions du système de types
On présente ici un système de types plus expressif permettant de capturer les extensions
de sémantique. In fine, cela permettra de prouver le théorème 6.1 qui est l’équivalent du théorème
5.1 mais pour le nouveau jugement de typage.
Définir un nouveau système de types revient à étendre le jugement de typage · � · : ·,
en modifiant certaines règles et en en ajoutant d’autres. Naturellement, la plupart des diffé-
rences porteront sur le traitement des pointeurs.
Pointeurs utilisateur
Le changement clef est l’ajout de pointeurs utilisateur. En plus des types pointeurs habituels
t ∗, on ajoute des types pointeurs utilisateur t @. La différence entre les deux représente
qui contrôle leur valeur (section 2.4).
Les différences sont les suivantes (figure 6.8) :
• Les types « t ∗ » s’appliquent aux pointeurs contrôlés par le noyau. Par exemple, prendre
l’adresse d’un objet de la pile noyau donne un pointeur noyau.
Type t ::= ...
| t @ Pointeur utilisateur
Type
de valeur
τ ::= ...
| τ @ Pointeur utilisateur
FIGURE 6.8 : Ajouts liés aux pointeurs utilisateur (par rapport aux figures 5.2 et 5.5)6.2. ANALYSE DE PROVENANCE DE POINTEURS 91
• Les types « t @ », quant à eux, s’appliquent aux pointeurs qui proviennent de l’espace
utilisateur. Ces pointeurs proviennent toujours d’interfaces particulières, comme les
appels système ou les paramètres passés aux implantations de la fonction ioctl.
L’ensemble des notations est résumé dans le tableau suivant :
Noyau Utilisateur
Syntaxe & x ♦ x
Valeur & ( � x) &� ♦� (x)
Type t ∗ t @
Accès ∗ x copy_*_user
Puisqu’on s’intéresse à la provenance des pointeurs, détaillons les règles qui créent, manipulent
et utilisent des pointeurs.
Sources de pointeurs
La source principale de pointeurs est l’opérateur & qui prend l’adresse d’une variable.
Celle-ci est bien entendue contrôlée par le noyau (dans le sens où son déréférencement est
toujours sûr). Cette construction crée donc des pointeurs noyau, et on maintient la règle suivante
:
Γ � l v : t
Γ � &l v : t ∗ (ADDR)
Manipulations de pointeurs
L’avantage du typage est que celui-ci suit le flot de données : si à un endroit une valeur de
type t est affectée à une variable, que le contenu de cette variable est placé puis retiré d’une
structure de données, il conserve ce type t. En particulier un pointeur utilisateur reste un
pointeur utilisateur.
Une seule règle consomme un pointeur et en retourne un. Elle concerne l’arithmétique
des pointeurs. On ne l’étend pas aux pointeurs utilisateur, car pour effectuer de l’arithmé-
rique, il faut observer la forme du pointeur sous-jacent. Si on veut laisser ♦� · opaque, il faut
donc interdire l’arithmétique sur les pointeurs utilisateur.
Utilisations de pointeurs
La principale restriction est que seuls les pointeurs noyau peuvent être déréférencés de
manière sûre. La règle capitale est donc la suivante (déjà introduite dans le chapitre 5) :
Γ � e : t ∗
Γ � ∗e : t
(LV-DEREF)
Ainsi, on interdit le déréférencement des expressions de type t @ à la compilation.
L’opérateur ♦ · transforme un pointeur selon la règle suivante :
Γ � l v : t
Γ � ♦ l v : t @
(ADDR-USER)92 CHAPITRE 6. EXTENSIONS DE TYPAGE
Les « fonctions » copy_from_user et copy_to_user sont typées de la manière suivante. Il
est à remarquer que ce ne sont pas vraiment des fonctions et qu’elles n’ont pas un type en
(t1,t2) → t, car il faudrait un type polymorphe pour pouvoir les appliquer à n’importe quel
type de pointeurs. Leur typage est donc plus proche de celui d’un opérateur.
Γ � ed : t ∗ Γ � es : t @
Γ � copy_from_user(ed ,es) : INT
(USER-GET)
Γ � ed : t @ Γ � es : t ∗
Γ � copy_to_user(ed ,es) : INT
(USER-PUT)
6.2.5 Sûreté du typage
Typage sémantique
La définition du typage sémantique doit aussi être étendue au cas ϕ = ♦� ϕ�
. En essence,
S-USERPTR énonce que traverser un constructeur ♦� · transforme un pointeur en pointeur
utilisateur.
m � &� ϕ : τ ∗
m � &� ♦� ϕ : τ @
(S-USERPTR)
τ�t
τ @�t @
(COMP-PTR)
Propriétés du typage
Lemme 6.1 (Inversion du typage). En plus des cas présentés dans le lemme 5.1, les cas suivants
permettent de remonter un jugement de typage.
• Si Γ � ♦ e : t, alors il existe t� tel que t = t� @ et Γ � e : t�
.
• Si Γ � copy_from_user(ed ,es) : t, alors t = INT et il existe t� tel que Γ � ed : t ∗ et Γ � es :
t @.
• Si Γ � copy_to_user(ed ,es) : t, alors t = INT et il existe t� tel que Γ � ed : t @ et Γ � es : t ∗.
Démonstration. Pour chaque forme syntaxique, on liste les règles qui ont comme conclusion
un jugement de typage portant sur celle-ci. Comme aucune autre règle ne convient, on peut
en déduire que c’est l’une de celles-ci qui a été appliquée, et donc qu’une des prémisses est
vraie.
Progrès et préservation
La propriété que nous cherchons à prouver est que le déréférencement d’un pointeur
dont la valeur est contrôlée par l’utilisateur ne peut se faire qu’à travers une fonction qui
vérifie la sûreté de celui-ci.
En fait il s’agit des théorèmes de sûreté du chapitre précédent.
Théorème 6.1 (Progrès pour les extensions noyau). Le théorème 5.1 reste valable avec les extensions
de ce chapitre.
La preuve de ce théorème est en annexe D.4.
Théorème 6.2 (Préservation pour les extensions noyau). Le théorème 5.2 reste valable avec les
extensions de ce chapitre.6.2. ANALYSE DE PROVENANCE DE POINTEURS 93
La preuve de ce théorème est en annexe D.5.
Ces extensions ne modifient pas les théorème de progrès et préservation sur les phrases
(théorèmes 5.3 et 5.4).
La sûreté du typage étant à nouveau établie, on a montré que l’ajout de types pointeurs
utilisateur suffit pour avoir une adéquation entre les extensions de sémantique de la section
6.2.1 et les extensions du système de type de la section 6.2.4.
Conclusion
En partant de SAFESPEAK tel que décrit dans les chapitres 4 et 5, on décrit une extension
de sa syntaxe et de sa sémantique. Cela permet d’exprimer les pointeurs vers l’espace utilisateur,
qui sont utilisés pour l’implantation d’appels système (chapitre 2).
Une première idée pour le typage de ces nouveaux pointeurs est de leur donner le même
type que les pointeurs classiques. On a montré ensuite que ce typage naïf ne suffit pas : il
permet en effet de faire fuiter de l’information, ce qu’on note par un cas d’erreur Ωsec . En
termes de systèmes de types, cela signifie que le théorème de progrès (théorème 5.1, page 77)
n’est plus vérifié.
Le langage des types est donc enrichi pour séparer les pointeurs utilisateur des pointeurs
noyau : les premiers sont explicitement construits par un ensemble de sources bien déterminé,
et les autres sont créés par exemple en prenant l’adresse d’une variable. La règle de typage
LV-DEREF assure que seuls les pointeurs noyau peuvent être déréférencés. Pour accéder
aux pointeurs utilisateur, il faut appeler les constructions copy_to_user et copy_from_user,
qui sont typées adéquatement et vérifient dynamiquement que les pointeurs dont la valeur
est contrôlée par l’utilisateur pointent vers l’espace utilisateur.CONCLUSION DE LA PARTIE II
On vient de décrire en détail un langage impératif, SAFESPEAK, et tout d’abord sa syntaxe
et sa sémantique d’évaluation dans le chapitre 4. Une des spécificités de cette sémantique est
l’utilisation de lentilles pour modifier les valeurs composées en profondeur.
Il y a plusieurs alternatives à cette présentation. La première est la solution classique qui
consiste à décrire les modifications de la mémoire en extension. C’est en général long et laborieux
puisqu’il faut définir les accès en lecture et écriture à chaque étape (avec des lentilles
on décrit ces deux opérations uniquement sur les briques du calcul, et la composition fait le
reste). La seconde solution est d’employer une sémantique monadique. Les transitions sont
alors encodées comme des actions monadiques qui représentent les modifications de la mé-
moire. Un des avantages de cette solution est qu’elle est très extensible. Par exemple, la propagation
des erreurs ou l’ajout de continuations légères (c’est-à-dire le support des fonctions
setjmp et longjmp) peuvent facilement être exprimés dans un formalisme monadique. Nous
avons préféré une présentation plus directe qui reste plus accessible à une audience habituée
à C, et suffisante compte tenu de la simplicité des constructions à interpréter dans le langage.
Ensuite, dans le chapitre 5, nous avons ajouté un système de types à SAFESPEAK. Le but
est de restreindre le genre d’erreurs qui peuvent arriver lors de l’évaluation d’un programme.
Par le théorème de progrès (théorème 5.1 , page 77), on interdit les erreurs qui signalent une
manipulation de valeurs incompatibles, l’accès à un champ de structure inconnu, et l’accès
à une variable inexistante. Et le théorème de préservation (théorème 5.2, page 78) formalise
le résultat classique qu’une étape d’évaluation ne modifie pas le typage. Une particularité de
SAFESPEAK est que son état mémoire est structuré, avec une pile de variables locales explicite.
On retrouve donc cette distinction dans le typage : les variables globales et les variable locales
sont séparées dans les environnements de typage Γ (page 64).
Enfin, dans le chapitre 6, on a étendu le langage pour exprimer la notion de pointeurs
utilisateur. Cela permet d’écrire des fonctions qui implantent des appels système. On a commencé
par montrer qu’une extension naïve du système de types ne suffit pas, car le théorème
de progrès est alors invalidé. On ajoute donc un type dédié aux pointeurs utilisateur. Les valeurs
de ce type sont créées explicitement et passées aux appels système. La règle de typage
du déréférencement est restreinte aux pointeurs noyau, ce qui permet de ré-établir les théorèmes
de progrès et préservation.
Notre technique de typage permet donc d’exprimer correctement les problèmes liés à la
manipulation mémoire lors des appels système, ainsi que décrits dans le chapitre 2 : c’est
une méthode simple pour détecter et empêcher les problèmes de sécurité qui proviennent
des pointeurs utilisateur.
Comme nous l’avons fait remarquer dans le chapitre 3, utiliser une technique de typage
pour étudier des propriétés sur les données a déjà été explorée dans l’outil CQual [FFA99], en
particulier sur les problèmes de pointeurs utilisateur [JW04].
En effet, si on remplace « t ∗ » par « KERNEL t ∗ » et « t @ » par « USER t ∗ », on obtient un
début de système de types qualifiés.
En revanche, il y a une différence importante : CQual modifie fondamentalement l’ensemble
du système de types, pas SAFESPEAK. Le jugement de typage de CQual a pour forme
générale Γ � e : q t (où Γ est un environnement de typage, e une expression, q un qualificateur
et t un type), alors que le nôtre a la forme plus classique Γ � e : t.
9596 CHAPITRE 6. EXTENSIONS DE TYPAGE
En intégrant q à la relation de typage, on ajoute un qualificateur à chaque type, même les
expressions pour lesquelles il n’est pas directement pertinent de déterminer qui les contrôle
(comme par exemple, un entier). Dans CQual, ceci permet de traiter de manière correcte le
transtypage. Par exemple, si e a pour type qualifié USER INT, alors (FLOAT ∗) e aura pour
type qualifié USER FLOAT ∗, et déréférencer cette expression produira une erreur de typage.
SAFESPEAK, dans son état actuel, ne permet pas de traiter les conversions de type et ne permet
donc pas de traiter ce cas.
Nous prenons, au contraire, l’approche de ne modifier le système de types que là où cela
est nécessaire, c’est-à-dire sur les types pointeurs. Cela permet de ne pas avoir à modifier en
profondeur un système de types existant.
Le modèle d’exécution est aussi très différent. CQual s’appuie sur un langage proche de
ML : un noyau de lambda-calcul avec des références. Le système de types sous-jacent est
proche de celui d’OCaml : du polymorphisme de premier ordre (avec la restriction habituelle
de généralisation des références) et du sous-typage structurel. En outre, leur approche repose
sur une gestion automatique de la mémoire. De notre côté, nous nous appuyons sur un modèle
mémoire plus proche de C, reposant sur une pile de variables et des pointeurs manipulés
à la main.
Une autre différence fondamentale est que le système de types de CQual fait intervenir
une relation de sous-typage. Le cas particulier du problème de déréférencement des pointeurs
utilisateur peut être traité dans ce cadre en posant KERNEL � USER pour restreindre
certaines opérations aux pointeurs KERNEL.
Notre approche, au contraire, n’utilise pas de sous-typage, mais consiste à définir un type
abstrait t @ partageant certaines propriétés avec t ∗ (comme la taille et la représentation)
mais incompatible avec certaines opérations. C’est à rapprocher des types abstraits dans les
langages comme OCaml et Haskell.
Les perspectives de travaux futurs sont également très différentes. Dans le cas des pointeurs,
même si le noyau Linux (et la plupart des systèmes d’exploitation) ne comportent que
deux espaces d’adressage, il est commun dans les systèmes embarqués de manipuler des
pointeurs provenant d’espaces mémoire indépendants : par exemple, de la mémoire flash, de
la RAM, ou une EEPROM de configuration. Ces différentes mémoires possèdent des adresses,
et un pointeur est interprété comme faisant référence à une ou l’autre selon le code dont il est
tiré. Lorsqu’il y a plus de deux espaces mémoire, aucun n’est plus spécifique que les autres :
le sous-typage, et donc un système de qualificateurs, n’est donc plus adapté. Au contraire il
est possible de créer un type de pointeurs pour chaque zone mémoire.Troisième partie
Expérimentation
Après avoir décrit notre solution dans la partie II, on présente ici son implantation.
Le chapitre 7 décrit l’implantation en elle-même : un prototype d’analyseur
de types, distribué avec le langage NEWSPEAK sur [☞3]. Il s’agit d’un logiciel libre,
distribué sous la license LGPL. La compilation depuis C est réalisée par l’utilitaire
C2NEWSPEAK. Celui-ci, tout comme le langage NEWSPEAK, proviennent d’EADS et
sont antérieurs à ce projet, mais le support de plusieurs extensions GNU C a été
développé spécialement pour pouvoir analyser le code du noyau Linux.
L’analyse en elle-même est implantée de la manière classique avec une variation
de l’algorithme W de Damas et Milner. Pour des raisons de simplicité et d’efficacité,
l’unification est faite en utilisant le partage de références plutôt que des substitutions.
L’algorithme d’inférence ne pose pas de problèmes de performance.
Ensuite, dans le chapitre 8, on évalue cette implantation sur le noyau Linux. On
commence par décrire comment fonctionnennt les appels système sous ce noyau,
et comment le confused deputy problem évoqué dans le chapitre 2 peut arriver dans
ce contexte. Dans une deuxième partie, on décrit le cas de deux bugs dans le noyau
Linux. Le premier porte sur un pilote de carte graphique Radeon et le second sur
l’appel système ptrace sur l’architecture Blackfin. Ils manipulent de manière non
sécurisée des pointeurs provenant de l’espace utilisateur. On montre que, pour chacun,
les analyses précédentes permettent de distinguer statiquement le cas incorrect
du cas corrigé.
97C H A P I T R E
7
IMPLANTATION
Dans ce chapitre, nous décrivons la mise en œuvre des analyses statiques précédentes.
Celles-ci ont été décrites sur SAFESPEAK, qui permet de modéliser des programmes C bien
typables.
Notre but est d’utiliser la représentation intermédiaire NEWSPEAK, développée par EADS.
Cela permet de profiter des nombreux outils existant déjà autour de ce langage, notamment
un compilateur depuis C et un analyseur statique par interprétation abstraite.
Mais cette représentation utilise un modèle mémoire différent. En effet il colle finement
à celui de C, où des constructions comme les unions empêchent la sûreté du typage. Défi-
nir SAFESPEAK a précisement pour but de définir un langage inspiré de C mais sur lequel le
typage peut être sûr. Il faudra donc adapter les règles de typage des chapitres 5 et 6. On reviendra
sur cette distinction entre les deux niveaux de sémantique dans la conclusion de la
partie III, page 123.
On commence par décrire le langage NEWSPEAK. Ensuite, nous décrivons la phase de
compilation, de C à NEWSPEAK, auquel on rajoute ensuite des étiquettes de types. Cellesci
sont calculées par un algorithme d’inférence de types à la Hindley-Milner, reposant sur
l’unification et le partage de références. Toutes ces étapes sont implantées dans le langage
OCaml [LDG+10, CMP03].
Le prototype décrit ici est disponible sur [☞3] sous une license libre, la GNU Lesser General
Public License.
7.1 NEWSPEAK et chaîne de compilation
NEWSPEAK est un langage intermédiaire conçu pour être un bon support d’analyses statiques,
contrairement à des langages conçus pour les programmeurs comme C. Sa sémantique
d’exécution (ainsi qu’une partie des étapes de compilation) est décrite dans [HL08]. Sa
syntaxe est donnée dans la figure 7.1.
La traduction depuis C est faite en trois étapes : prétraitement du code source par un
outil externe, compilation séparée de C prétraité vers des objets NEWSPEAK, puis liaison de
ces différentes unités de compilation. Il est aussi possible de compiler directement du code
Ada vers un objet NEWSPEAK.
La première étape consiste à prétraiter les fichiers C source avec le logiciel cpp, comme
pour une compilation normale. Cette étape interprète les directives de prétraitement comme
#include, #ifdef. À cet étape, les commentaires sont aussi supprimés.
99100 CHAPITRE 7. IMPLANTATION
Instruction s ::= Set(l v,e,st) Affectation
| Copy(l v,l v,n) Copie
| Guard(e) Garde
| Decl(var,t,bl k) Déclaration
| Select(bl k,bl k) Branchement
| InfLoop(bl k) Boucle infinie
| DoWith(bl k,x) Nommage de bloc
| Goto(x) Saut
| Call([(ei ,ti)], f , [(l vi ,ti)]) Appel de fonction
Bloc bl k ::= [si] Liste d’instructions
Valeur gauche l v ::= Local(x) Locale
| Global(x) Globale
| Deref(e,n) Déréférencement
| Shift(l v,e) Décalage
Expression e ::= CInt(n) Entier
| CFloat(d) Flottant
| Nil Pointeur nul
| Lval(l v,t) Accès mémoire
| AddrOf(l v) Adresse de variable
| AddrOfFun(x, [ti], [ti]) Adresse de fonction
| UnOp(unop,e) Opérateur unaire
| BinOp(binop,e1,e2) Opérateur binaire
Fonction f ::= FunId(x) Appel par nom
| FunDeref(e) Appel par pointeur
Type t ::= Scalar(st) Type scalaire
| Array(t,n) Tableau
| Region([(ni ,ti)],n�
) Structure/union
Type scalaire st ::= Int(n) Entier
| Float(n) Flottant
| Ptr Pointeur sur données
| FunPtr Pointeur sur fonction
FIGURE 7.1 : Syntaxe simplifiée de NEWSPEAK7.1. NEWSPEAK ET CHAÎNE DE COMPILATION 101
Une fois cette passe effectuée, le résultat est un ensemble de fichiers C prétraités ; c’est-
à-dire des unités de compilation.
Sur cette représentation (du C prétraité), il est possible d’ajouter des annotations de la
forme /*!npk [...] */ qui pourront être accessibles dans l’arbre de syntaxe abstraite des
passes suivantes.
À ce niveau, les fichiers sont passés à l’outil C2NEWSPEAK qui les traduit vers NEWSPEAK.
Comme il sera décrit dans la section 8.1, la plupart des extensions GNU C sont acceptées
en plus du C ANSI. Dans cette étape, les types et les noms sont résolus, et le programme est
annoté de manière à rendre les prochaines étapes indépendantes du contexte. Par exemple,
chaque déclaration de variable est adjointe d’une description complète du type.
Lors de cette étape, le flot de contrôle est également simplifié (figure 7.2). De plus, les
constructions ambigües en C comme i = i++ sont transformées pour que leur évaluation
se fasse dans dans un ordre explicite. Un choix arbitraire est alors fait ; par exemple, les arguments
de fonctions sont évalués de droite à gauche (la raison étant sur Intel, les arguments
sont empilés dans ce sens).
Au contraire, NEWSPEAK propose un nombre réduit de constructions. Rappelons que le
but de ce langage est de faciliter l’analyse statique : des constructions orthogonales permettent
donc d’éviter la duplication de règles sémantiques, ou de code, lors de l’implantation
d’un analyseur.
Par exemple, plutôt que de fournir une boucle while, une boucle do/while et une boucle
for, NEWSPEAK fournit une unique boucle WHILE(1){·}. La sortie de boucle est compilée vers
un GOTO [EH94], qui est toujours un saut vers l’avant (similaire à un « break » généralisé).
NEWSPEAK est conçu pour l’analyse statique par interprétation abstraite. Il a donc une
vue de bas niveau sur les programmes. Par exemple, aucune distinction n’est faite entre l’accès
à un champ et l’accès à un élément d’un tableau (tous deux sont traduits par un décalage
numérique depuis le début de la zone mémoire). De plus, les unions et les structures sont regroupées
sous forme des types « régions » qui associent à un décalage un type de champ. Pour
supprimer ces ambiguïtés, il faut s’interfacer dans les structures internes de C2NEWSPEAK, où
les informations nécessaires sont encore présentes.
int x;
x = 0;
while (x < 10) {
x++;
}
int32 x;
x =(int32) 0;
do {
while (1) {
choose {
-->
guard((10 > x_int32));
-->
guard(! (10 > x_int32));
goto lbl1;
}
x =(int32) coerce[-2**31,2**31-1] (x_int32 + 1);
}
} with lbl1: {
}
FIGURE 7.2 : Compilation du flot de contrôle en NEWSPEAK. Le code source C, à gauche, est
compilé en NEWSPEAK, à droite.102 CHAPITRE 7. IMPLANTATION
Ensuite, les différents fichiers sont liés ensemble. Cette étape consiste principalement à
s’assurer que les hypothèses faites par les différentes unités de compilation sont cohérentes
entre elles. Les objets marqués static, invisibles à l’extérieur de leur unité de compilation,
sont renommés afin qu’ils aient un nom globalement unique. Cette étape se conclut par la
création d’un fichier NEWSPEAK.
7.2 L’outil ptrtype
La dernière étape est réalisée dans un autre outil nommé ptrtype, d’environ 1600 lignes
de code OCaml, et réalisé dans le cadre de cette thèse. Elle consiste en l’implantation d’un
algorithme d’inférence pour les systèmes de types décrits dans les chapitres 5 et 6. Puisqu’ils
sont suffisamment proches du lambda calcul simplement typé, on peut utiliser une variante
de l’algorithme W de Damas et Milner [DM82].
Cela repose sur l’unification : on dispose d’une fonction permettant de créer des inconnues
de type, et d’une fonction pour unifier deux types partiellement inconnus. En pratique,
on utilise l’optimisation classique qui consiste à se reposer sur le partage de références pour
réaliser l’unification, plutôt que de faire des substitutions explicites. Puisque ces systèmes de
types sont monomorphes, on présente une erreur si des variable de type libres sont présentes.
Architecture de ptrtype
Bâti autour de cette fonction, le programme ptrtype lit un programme NEWSPEAK et réalise
l’inférence de types. Si l’argument passé à ptrtype est un fichier C, il est tout d’abord
compilé vers NEWSPEAK grâce à l’utilitaire C2NEWSPEAK. En sortie, il affiche soit le programme
complètement annoté, soit une erreur. Ce comportement est implanté dans la fonction de la
figure 7.3.
• Grâce à la fonction convert_unit : Newspeak.t -> unit Tyspeak.t, on ajoute des
étiquettes « vides » (toutes égales à () : unit) 1.
• L’ensemble des fonctions du programme est trié topologiquement selon la relation �
définie par f � g
def
== « g apparaît dans la définition de f ». Cela est fait en construisant
une représentation de � sous forme de graphe, puis en faisant un parcours en largeur
de celui-ci. Pour le moment, les fonctions récursives et mutellement récursives ne sont
pas supportées.
• Les annotations extérieures sont alors lues (variable exttbl), ce qui permet de créer un
environnement initial. On peut y introduire les annotations suivantes :
Annotation Signification
/*!npk f : (Int) -> Int */ f est une fonction prenant comme argument
un entier et renvoyant un entier.
/*!npk userptr x */ x a pour type a @, où a est une nouvelle inconnue
de type.
/*!npk userptr_fieldp x f */ x a pour type {f : a @;...} ∗, où a est une
nouvelle inconnue de type.
• Les types de chaque fonction sont ensuite inférés, par le biais de la fonction suivante :
1. ’a Tyspeak.t est le type des programmes NEWSPEAK où on insère des étiquettes de type ’a à tous les
niveaux.7.2. L’OUTIL PTRTYPE 103
let process_npk npk =
let tpk = Npk2tpk.convert_unit npk in
let order = Topological.topological_sort (Topological.make_graph npk) in
let function_is_defined f =
Hashtbl.mem tpk.Tyspeak.fundecs f
in
let (internal_funcs, external_funcs) =
List.partition function_is_defined order
in
let exttbl = Printer.parse_external_type_annotations tpk in
let env =
env_add_external_fundecs exttbl external_funcs Env.empty
in
let s = Infer.infer internal_funcs env tpk in
begin
if !Options.do_checks then
Check.check env s
end;
Printer.dump s
FIGURE 7.3 : Fonction principale de ptrtype
val infer : Newspeak.fid list (* liste triée de fonctions à typer *)
-> Types.simple Env.t (* environnement initial *)
-> 'a Tyspeak.t (* programme à analyser *)
-> Types.simple Tyspeak.t
• S’il n’y a pas d’erreurs, le programme obtenu, de type Types.simple Tyspeak.t, est
affiché sur le terminal.
Unification
La fonction unify prend en entrée deux représentations de types pouvant contenir des
inconnues de la forme Var n, et retourne une liste de contraintes indiquant les substitutions
à faire.
Cet algorithme est décrit en pseudo-code ML en figure 7.4. Pour simplifier, on le présente
comme retournant une liste, mais il est implanté de manière destructive : Var n contient une
référence qui peut être modifiée, et grâce au partage c’est équivalent à substituer dans tous
les types qui contiennent Var n.
La fonction d’unification prend un chemin différent selon la forme des deux types d’entrée
:
• si les deux types sont inconnus (de la forme Var n), on substitue l’un par l’autre.
• si un type est inconnu et pas l’autre, il faut de la même manière faire une substitution.
Mais en faisant ça inconditionnellement, cela peut poser problème : par exemple, en104 CHAPITRE 7. IMPLANTATION
Contrainte c ::= n �→ t Substitution
| (l : t) ∈ X Variable de rangée
1: function UNIFY(ta,tb)
2: match (ta,tb) with
3: | VAR na, VAR nb ⇒
4: if na = nb then
5: return [ ]
6: else
7: return [na �→ tb]
8: end if
9: | VAR na,tb ⇒
10: if OCCURS(na,tb) then
11: erreur
12: end if
13: return [na �→ tb]
14: | ta, VAR nb ⇒ return UNIFY(tb,ta)
15: | INT, INT ⇒ return [ ]
16: | FLOAT, FLOAT ⇒ return [ ]
17: | a[ ],b[ ] ⇒ return UNIFY(a,b)
18: | a ∗,b ∗ ⇒ return UNIFY(a,b)
19: | a @,b @ ⇒ return UNIFY(a,b)
20: | (la) → ra, (lb) → rb ⇒
21: r ← UNIFY(ra, rb)
22: n ← LENGTH(la)
23: if LENGTH(lb) �= n then
24: erreur
25: end if
26: for i = 0 to n −1 do
27: r ← r ∪ UNIFY(la[i],lb[i])
28: end for
29: return r
30: | A = {a1 : t1;...;an : tn;...Xa},B = {b1 : s1;...;bm : um;...Xb} ⇒
31: r ← �
32: for {(t,u)/∃l.(l : t) ∈ A ∧(l : u) ∈ B} do
33: r ← r ∪ UNIFY(t,u)
34: end for
35: for {(l,t) ∈ A/∀(l� : u) ∈ B.l �= l�
} do
36: r ← r ∪[(l : t) ∈ XB ]
37: end for
38: for {(l,u) ∈ B/∀(l� : t) ∈ A.l �= l�
} do
39: r ← r ∪[(l : u) ∈ XA]
40: end for
41: return r
42: | _ ⇒ erreur
43: end function
FIGURE 7.4 : Algorithme d’unification7.2. L’OUTIL PTRTYPE 105
let unify a b =
if !Options.lazy_unification then
Queue.add (Unify (a, b)) unify_queue
else
unify_now a b
FIGURE 7.5 : Unification directe ou retardée
tentant d’unifier a avec KPtr(a), on pourrait créer une substitution cyclique. Pour éviter
cette situation, il suffit de s’assurer que le type inconnu n’est pas présent dans le
type à affecter. C’est le but de la fonction occurs(n, t) qui calcule si Var n apparaît
dans t.
• si les deux types sont des types de base (comme INT ou FLOAT) égaux, on ne fait rien.
• si les deux types sont des constructeurs de type, il faut que les constructeurs soient
égaux. On unifie en outre leurs arguments deux à deux.
• dans les autres cas, l’algorithme échoue.
Le traitement des types structures est géré dans l’implantation d’une manière différente
de la présentation du chapitre 4. Au lieu d’accéder directement au type complet S à chaque
accès x.lS, on n’obtient qu’un nom de champ à chaque accès. C’est-à-dire qu’on va par exemple
inférer le type {l : INT;...X} où ...X désigne l’ensemble des champs inconnus (on rappelle
que dans la sémantique qui nous intéresse, ceux-ci n’ont pas un ordre défini au sein d’une
structure).
Plus précisément, si on cherche à unifier les types structures A = {a1 : t1;...;an : tn;...Xa}
et B = {b1 : s1;...;bm : um;...Xb}, il faut partitionner l’ensemble des champs en 3 : ceux qui
apparaissent dans les deux structures, ceux qui apparaissent dans A mais pas dans B, et ceux
qui apparaissent dans B mais pas dans A.
• Pour tous les champs l tels que l : t ∈ A et l : u ∈ B, on unifie t et u.
• Pour les champs l qui sont dans A mais pas dans B : on ajoute l à Xb.
• Pour les champs l qui sont dans B mais pas dans A : on ajoute l à Xa.
Cela se rapproche du polymorphisme de rangée [RV98] présent dans les langages comme
OCaml. À la fin de l’inférence, on considère que la variable de rangée « ...X » est vide. Elle
n’apparaît donc pas dans les types.
La fonction unify, appelée dans toutes les fonctions d’inférence, peut retarder l’unification
(figure 7.5). Dans ce cas, la paire de types à unifier est mise dans une liste d’attente qui
sera unifiée après le parcours du programme. Le but est d’instrumenter l’inférence de types
afin de pouvoir en faire une exécution « pas à pas ».
Inférence de types
Il ne reste plus qu’à remplacer les étiquettes de type unit par des étiquettes de type
simple (autrement dit de vraies représentations de types), à l’aide de la fonction unify.
Cette étape se fait de manière impérative. Cela permet de ne pas avoir à réaliser de substitutions
explicites. À la place, on repose sur le partage et les références, qui représentent
les inconnues de type. Lorsque celles-ci sont résolues, il suffit de muter une seule fois la ré-
férence, et le partage fait que ce changement sera visible partout. Plus précisément, on peut
créer de nouveaux types avec la fonction new_unknown et unifier deux types avec la fonction
unify. Leurs types sont :106 CHAPITRE 7. IMPLANTATION
val new_unknown : unit -> Types.simple
val unify : Types.simple -> Types.simple -> unit
La fonction infer s’appuie sur un ensemble de fonctions récursivement définies portant
sur chaque type de fragment : infer_fdec pour les déclarations de fonction, infer_exp pour
les expressions, infer_stmtkind pour les instructions, etc. Grâce au lemme 5.1, on sait quelle
règle appliquer en fonction de l’expression ou instruction considérée. Notons que, même
si le programme NEWSPEAK est décoré d’informations de types (celles qui existent dans le
programme C), elles ne sont pas utilisées.
Les règles de typage sont implantées par new_unknown et unify. Par exemple, pour typer
une déclaration (qui n’a pas de valeur initiale en NEWSPEAK), on crée un nouveau type t0. On
étend l’environnement courant avec cette nouvelle association et, sous ce nouvel environnement,
on type le bloc de portée de la déclaration (figure 7.6).
let rec infer_stmtkind env sk =
match sk with
(* [...] *)
| T.Decl (n, nty, _ty, blk) ->
let var = T.Local n in
let t0 = new_unknown () in
let new_env = Env.add (VLocal n) (Some nty) t0 env in
let blk’ = infer_blk new_env blk in
let ty = lval_type new_env var in
T.Decl (n, nty, ty, blk’)
(* [...] *)
| T.Call (args, fexp, rets) ->
let infer_arg (e, nt) =
let et = infer_exp env e in
(et, nt)
in
let infer_ret (lv, nt) =
(infer_lv env lv, nt)
in
let args’ = List.map infer_arg args in
let rets’ = List.map infer_ret rets in
let t_args = List.map (fun ((_, t), _) -> t) args’ in
let t_rets = List.map (fun (lv, _) -> lval_type env lv) rets’ in
let (fexp’, tf) = infer_funexp env fexp in
let call_type = Fun (t_args, t_rets) in
unify tf call_type;
T.Call (args’, fexp’, rets’)
FIGURE 7.6 : Inférence des déclarations de variable et appels de fonction
De même, pour typer un appel de fonction, on infère le type de ses arguments et valeurs
gauches de retour. On obtient également le type de la fonction (à partir du type de la fonction7.3. EXEMPLE 107
présent dans l’environnement, ou du type du pointeur de fonction qui est déréférencé), et on
unifie ces deux informations.
Pour additionner deux flottants, par exemple, on unifie leurs types avec FLOAT. Le résultat
est également de type FLOAT. Cela correspond à la règle OP-FLOAT.
let infer_binop op (_, a) (_, b) =
match op with
(* [...] *)
| N.PlusF _ ->
unify a Float;
unify b Float;
Float
Pour prendre l’adresse d’une variable, la règle ADDR s’applique : on prend le type de la
valeur gauche et on construit un pointeur noyau à partir de lui.
| T.AddrOf lv ->
let lv' = infer_lv env lv in
let ty = lval_type env lv in
(T.AddrOf lv', Ptr (Kernel, ty))
Enfin, pour déréférencer une expression, on unifie tout d’abord son type avec le type d’un
pointeur noyau.
| T.Deref(e, _sz) ->
let (_, te) = infer_exp env e in
let t = new_unknown () in
unify (Ptr (Kernel, t)) te;
t
7.3 Exemple
Lançons l’analyse sur un petit exemple (stocké dans le fichier example.c) :
int f(int *x) { return (*x + 1); }
L’exécution de notre analyseur affiche un programme complètement annoté :
% ptrtype example.c
1 f : (KPtr (Int)) -> (Int)
2 Int (example.c:1#4)^f(KPtr (Int) x) {
3 (.c:3#4)^!return =(int32)
4 (coerce[-2147483648,2147483647]
5 ( ( ([(x_KPtr (Int) : KPtr (Int))]32_Int
6 : Int
7 )
8 + (1 : Int)
9 ) : Int
10 ) : Int
11 );
12 }108 CHAPITRE 7. IMPLANTATION
• Ligne 1 : le type inféré de la fonction f est affiché. Il est calculé entièrement en fonction
des opérations effectuées ; on n’utilise pas les étiquettes de type du programme.
• Ligne 2 : le code de la fonction est affiché. Les indications de la forme (F:L#C)ˆX correspondent
à la déclaration d’une variable X, dans le fichier F, ligne L et colonne C.
• Ligne 3 : en NEWSPEAK, la valeur de retour est une variable qui est affectée. On sépare
ainsi le flot de données (définir la valeur de retour) du flot de contrôle (sortir de la
fonction). C’est un équivalent de la variable R introduite pour le typage des fonctions
(page 69). L’affectation est notée =(int32) car en NEWSPEAK elle est décorée du type
des opérandes. Cette information n’est pas utilisée dans l’inférence de types.
• Ligne 4 : l’opérateur coerce[a,b] sert à détecter les débordements d’entiers lors d’une
analyse de valeurs par interprétation abstraite. Dans le cas de notre analyse, les valeurs
ne sont pas pertinentes et cet opérateur peut être vu que comme l’identité.
• Ligne 5 : le déréférencement d’une valeur gauche e est noté [e]_n. Il est annoté par la
taille de l’opérande (32 bits ici). De plus, l’accès à une valeur gauche (pour la transformer
en expression) est annoté par son type, ce qui explique la verbosité de cette ligne.
• les autres lignes sont des étiquettes de type inférées sur les expressions 1, ∗x, 1, ∗x +1
et la valeur de retour coerce[−231, 231 −1](∗x +1).
Un exemple de détection d’erreur sera décrit dans la section 8.6.
7.4 Performance
Même s’il est simple en apparence, le problème de l’inférence de types par l’algorithme W
est EXP-complet [Mai90], c’est-à-dire que les algorithmes efficaces ont une complexité exponentielle
en la taille du programme. Cependant, lorsqu’on borne la « taille » des types, celle-ci
devient quasi-linéaire [McA03], ce qui signifie qu’il n’y a pas de problème de performance à
attendre en pratique.
Dans notre cas, on utilise une variante de l’algorithme W pour un langage particulièrement
simple. En particulier il n’y a pas de polymorphisme, ni de fonctions imbriquées, et
les types des valeurs globales sont écrites par le programmeur. Cela permet de borner leur
taille. En pratique, sur les exemples testés (jusqu’à quelques centaines de lignes de code)
nous n’avons pas noté de délai d’exécution notable.
En revanche, la compilation de C vers NEWSPEAK peut être plus coûteuse, notamment
lorsque le fichier d’entrée est de taille importante. Le temps de traitement est plus long que
celui d’un compilateur comme gcc ou clang. C2NEWSPEAK a toutefois été utilisé pour compiler
des projets de l’ordre du million de lignes de code source prétraité, et son exécution ne
prenait pas plus de quelques minutes.
À titre d’illustration, nous avons mesuré les performances de C2NEWSPEAK et ptrtype sur
l’exemple « Blackfin » du chapitre suivant. Celui consiste en un fichier prétraité de 853 lignes
de code C. Exécuter 1000 fois C2NEWSPEAK sur ce fichier prend 36.3 secondes, alors qu’exé-
cuter 1000 fois ptrtype sur le fichier NEWSPEAK résultant ne dure que 8.1 secondes (par comparaison,
lancer 1000 fois /bin/true, commande qui ne fait rien, prend 1.6 seconde).
Les structures internes de C2NEWSPEAK ont déjà été améliorées, et d’autres optimisations
sont certainement possibles, mais la performance n’est pas bloquante pour le moment : une
fois que le code est compilé, on peut réutiliser le fichier objet NEWSPEAK pour d’autres analyses.
La compilation est donc relativement rare.7.4. PERFORMANCE 109
Conclusion
Les analyses de typage correspondant aux chapitres 5 et 6 ont été implantées sous forme
d’un prototype utilisant le langage NEWSPEAK développé par EADS. Cela permet de réutiliser
les phases de compilation déjà implantées, et d’exprimer les règles de typage sur un langage
suffisament simple.
On utilise un algorithme par unification, qui donne une forme simple au programme
d’inférence. Pour chaque expression ou instruction à typer, on détermine grâce au lemme 5.1
quelle règle il faut appliquer. Ensuite, on génère les inconnues de type nécessaires pour appliquer
cette règle et on indique les contraintes en appelant la fonction d’unification.
Ce prototype comporte environ 1600 lignes de code OCaml. Il est disponible sous license
libre sur [☞3]. Il a été pensé pour traiter un type de code particulier, à savoir le noyau Linux.
On montre dans le chapitre suivant que cet objectif est atteint, puisqu’il permet de détecter
plusieurs bugs.C H A P I T R E
8
ÉTUDE DE CAS : LE NOYAU LINUX
Le noyau Linux, abordé dans le chapitre 2, est un noyau de système d’exploitation développé
depuis le début des années 90 et « figure de proue » du mouvement open-source. Au
départ écrit par Linus Torvalds sur son ordinateur personnel, il a été porté au fil des années
sur de nombreuses architectures et s’est enrichi de nombreux pilotes de périphériques. Dans
la version 3.13.1 (2014), son code source comporte 12 millions de lignes de code (en grande
majorité du C) dont 58% de pilotes.
Même si le noyau est monolithique (la majeure partie des traitements s’effectue au sein
d’un même fichier objet), les sous-systèmes sont indépendants. C’est ce qui permet d’écrire
des pilotes de périphériques et des modules.
Ces pilotes manipulent des données provenant de l’utilisateur, notamment par pointeur.
Comme on l’a vu, cela peut poser des problèmes de sécurité si on déréférence ces pointeurs
sans vérification.
Dans ce chapitre, on met en œuvre sur le noyau Linux le système de types décrit dans le
chapitre 6, ou plus précisément l’outil ptrtype du chapitre 7.
Pour montrer que le système de types capture cette propriété et que l’implantation est
utilisable, on étudie les cas de deux bugs qui ont touché le noyau Linux. À chaque fois, dans
une routine correspondant à un appel système, un pointeur utilisateur est déréférencé directement,
pouvant provoquer une fuite d’informations confidentielles dans le noyau.
On commence par décrire les difficultés rencontrées pour analyser le code du noyau Linux.
On décrit ensuite l’implantation du mécanisme d’appels système dans ce noyau, et en
quoi cela peut poser des problèmes. On détaille enfin les bugs étudiés, et comment les adapter
pour traiter le code en question.
8.1 Spécificités du code noyau
Linux est écrit dans le langage C, mais pas dans la version qui correspond à la norme. Il
utilise le dialecte GNU C qui est celui que supporte GCC. Une première difficulté pour traiter
le code du noyau est donc de le compiler.
Pour traduire ce dialecte, il a été nécessaire d’adapter C2NEWSPEAK. La principale particularité
est la notation __attribute__((...)) qui peut décorer les déclarations de fonctions,
de variables ou de types.
Par exemple, il est possible de manipuler des étiquettes de première classe : si « lbl: » est
présent avant une instruction, on peut capturer l’adresse de celle-ci avec void *p = &&lbl
et y sauter indirectement avec goto *p.
111112 CHAPITRE 8. ÉTUDE DE CAS : LE NOYAU LINUX
Une autre fonctionnalité est le concept d’instruction-expression : ({bloc}) est une expression,
dont la valeur est celle de la dernière expression évaluée lors de bloc.
Les attributs, quant à eux, rentrent dans trois catégories :
• les annotations de compilation ; par exemple, used désactive l’avertissement « cette variable
n’est pas utilisée ».
• les optimisations ; par exemple, les objets marqués hot sont groupés de telle manière
qu’ils se retrouvent en cache ensemble.
• les annotations de bas niveau ; par exemple, aligned(n) spécifie qu’un objet doit être
aligné sur au moins n bits.
Dans notre cas, toutes ces annotations peuvent être ignorées, mais il faut tout de même
adapter l’analyse syntaxique pour les ignorer. En particulier, pour le traitement du noyau Linux,
il a fallu traiter certaines formes de la construction typeof qui n’étaient pas supportées.
De plus, pour que le code noyau soit compilable, il est nécessaire de définir certaines
macros. En particulier, le système de configuration de Linux utilise des macros nommées
CONFIG_* pour inclure ou non certaines fonctionnalités. Il a donc fallu faire un choix ; nous
avons choisi la configuration par défaut. Pour analyser des morceaux plus importants du
noyau, il faudrait définir un fichier de configuration plus important.
8.2 Appels système sous Linux
Dans cette section, nous allons voir comment ces mécanismes sont implantés dans le
noyau Linux. Une description plus détaillée pourra être trouvée dans [BC05] ou, pour le cas
de la mémoire virtuelle, dans [Gor04].
Deux rings sont utilisés : en ring 0, le code noyau et, en ring 3, le code utilisateur.
Une notion de tâche similaire à celle décrite dans la section 2.2 existe : les tâches s’exé-
cutent l’une après l’autre, le changement s’effectuant sur interruptions.
Pour faire appel aux services du noyau, le code utilisateur doit faire appel à des appels
système, qui sont des fonctions exécutées par le noyau. Chaque tâche doit donc avoir deux
piles : une pile « utilisateur », qui sert pour l’application elle-même, et une pile « noyau », qui
sert aux appels système.
Grâce à la mémoire virtuelle, chaque processus possède sa propre vue de la mémoire dans
son espace d’adressage (figure 8.1), et donc chacun gère un ensemble de tables de pages et
une valeur de CR3 associée (ce mécanisme a été abordé page 17). Au moment de changer le
processus en cours, l’ordonnanceur charge donc le CR3 du nouveau processus.
Les adresses basses (inférieures à PAGE_OFFSET = 3 Gio = 0xc0000000) sont réservées à
l’utilisateur. On y trouvera par exemple : le code du programme, les données du programme
(variables globales), la pile utilisateur, le tas (mémoire allouée par malloc et fonctions similaires),
ou encore les bibliothèques partagées.
Au dessus de PAGE_OFFSET, se trouve la mémoire réservée au noyau. Cette zone contient
le code du noyau, les piles noyau des processus, etc.
0 3 Go 4 Go
FIGURE 8.1 : L’espace d’adressage d’un processus. En gris clair, les zones accessibles à tous les
niveaux de privilèges : code du programme, bibliothèques, tas, pile. En gris foncé, la mémoire
du noyau, réservée au mode privilégié.8.3. RISQUES 113
Les programmes utilisateur s’exécutant en ring 3, ils ne peuvent pas contenir d’instructions
privilégiées, et donc ne peuvent pas accéder directement au matériel. Pour que ces programmes
puissent interagir avec le système (afficher une sortie, écrire sur le disque. . . ), le
mécanisme des appels système est nécessaire. Il s’agit d’une interface de haut niveau entre
les rings 3 et 0. Du point de vue du programmeur, il s’agit d’un ensemble de fonctions C « magiques
» qui font appel au système d’exploitation pour effectuer des opérations.
Par exemple, le programmeur peut appeller la fonction getpid pour connaître le numéro
du processus courant. Cela passe par une fonction getpid dans la bibliothèque C, en espace
utilisateur. Celle-ci va invoquer (via un mécanisme non pertinent ici) la fonction sys_getpid
du noyau (figure 8.2).
Comme les piles sont différentes entre les espaces, la convention d’appel est différente :
les arguments sont copiés directement par les registres.
SYSCALL_DEFINE0(getpid)
{
return task_tgid_vnr(current);
}
FIGURE 8.2 : Fonction de définition d’un appel système
La macro SYSCALL_DEFINE0 permet de nommer la fonction sys_getpid, et définit entre
autres des points d’entrée pour les fonctionnalités de débogage du noyau. Le corps de la fonction
fait directement référence aux structures de données internes du noyau pour retourner
le résultat voulu.
8.3 Risques
Ainsi que décrit dans la section 2.4, cela peut poser un problème de manipuler des pointeurs
contrôlés par l’utilisateur au sein d’une routine de traitement d’appel système.
Si le déréférencement est fait sans vérification, un utilisateur mal intentionné peut forger
un pointeur vers le noyau (en déterminant des adresses valides dans l’espace noyau entre
0xc0000000 et 0xffffffff). En provoquant une lecture sur ce pointeur, des informations
confidentielles peuvent fuiter ; et, en forçant une écriture, il est possible d’augmenter ses privilèges,
par exemple en devenant super-utilisateur (root). En pratique, il n’est pas toujours
possible d’accéder à la mémoire. La mémoire utilisateur peut par exemple avoir été placée
en zone d’échange sur le disque, ou swap. À ce moment là, l’erreur provoquera tout de même
un déni de service. Plus de détails sur ce mécanisme, et le fonctionnement de la mémoire
virtuelle dans Linux, peuvent être trouvés dans [Jon10].
8.4 Premier exemple de bug : pilote Radeon KMS
On décrit le cas d’un pilote vidéo qui contenait un bug de pointeur utilisateur. Il est ré-
pertorié sur http://freedesktop.org en tant que bug #29340.
Pour changer de mode graphique, les pilotes de GPU peuvent supporter le Kernel Mode
Setting (KMS).
Pour configurer un périphérique, l’utilisateur communique avec le pilote noyau avec le
mécanisme d’ioctls (pour Input/Output Control). Ils sont similaires à des appels système,
mais spécifiques à un périphérique particulier. Le transfert de contrôle est similaire à ce qui
a été décrit dans la section précédente : les applications utilisateurs appellent la fonction114 CHAPITRE 8. ÉTUDE DE CAS : LE NOYAU LINUX
ioctl() de la bibliothèque standard, qui provoque une interruption. Celle-ci est traitée par
la fonction sys_ioctl() qui appelle la routine de traitement dans le bon pilote de périphé-
rique.
Les fonctions du noyau implantant un ioctl sont donc vulnérables à la même classe d’attaques
que les appels système, et donc doivent être écrites avec une attention particulière.
Le code de la figure 8.3 est présent dans le pilote KMS pour les GPU AMD Radeon.
/* drivers/gpu/drm/radeon/radeon_kms.c */
int radeon_info_ioctl(struct drm_device *dev, void *data,
struct drm_file *filp) {
struct radeon_device *rdev = dev->dev_private;
struct drm_radeon_info *info;
struct radeon_mode_info *minfo = &rdev->mode_info;
uint32_t *value_ptr;
uint32_t value;
struct drm_crtc *crtc;
int i, found;
info = data;
value_ptr = (uint32_t *) ((unsigned long)info->value);
/*=>*/ value = *value_ptr;
/* ... */
}
FIGURE 8.3 : Code de la fonction radeon_info_ioctl
On peut voir que l’argument data est converti en un struct drm_radeon_info *. Un
pointeur value_ptr est extrait de son champ value, et finalement ce pointeur est déréferencé.
Cependant, l’argument data est un pointeur vers une structure (allouée en espace noyau)
du type donné dans la figure 8.4, dont les champs proviennent d’un appel utilisateur de
ioctl().
/* from include/drm/radeon_drm.h */
struct drm_radeon_info {
uint32_t request;
uint32_t pad;
uint64_t value;
};
FIGURE 8.4 : Définition de struct drm_radeon_info
Pour mettre ce problème en évidence, nous avons annoté la fonction radeon_info_ioctl
de telle manière que son second paramètre soit un pointeur noyau vers une structure contenant
un champ contrôlé par l’utilisateur, value.
L’intégralité de ce code peut être trouvée en annexe A.
La bonne manière de faire a été publiée avec le numéro de commit d8ab3557 (figure 8.5)
(DRM_COPY_FROM_USER étant une simple macro pour copy_from_user). Dans ce cas, on n’obtient
pas d’erreur de typage.8.5. SECOND EXEMPLE : PTRACE SUR ARCHITECTURE BLACKFIN 115
--- a/drivers/gpu/drm/radeon/radeon_kms.c
+++ b/drivers/gpu/drm/radeon/radeon_kms.c
@@ -112,7 +112,9 @@
info = data;
value_ptr = (uint32_t *)((unsigned long)info->value);
- value = *value_ptr;
+ if (DRM_COPY_FROM_USER(&value, value_ptr, sizeof(value)))
+ return -EFAULT;
+
switch (info->request) {
case RADEON_INFO_DEVICE_ID:
value = dev->pci_device;
FIGURE 8.5 : Patch résolvant le problème de pointeur utilisateur. La ligne précédée par un
signe - est supprimée et remplacée par les lignes précédées par un signe +.
8.5 Second exemple : ptrace sur architecture Blackfin
Le noyau Linux peut s’exécuter sur l’architecture Blackfin, qui est spécialisée dans le traitement
du signal. Le problème de manipulation des pointeurs utilisateur auquel nous nous
intéressons peut également s’y produire.
En particulier nous nous intéressons à l’appel système ptrace. Il permet à un processus
d’accéder à la mémoire et de contrôler l’exécution d’un autre processus, par exemple à
des fins de débogage. Ainsi, ptrace(PTRACE_PEEKDATA, p, addr) renvoie la valeur du mot
mémoire à l’adresse addr dans l’espace d’adressage du processus p.
Comme pour la plupart des appels système, la fonction ptrace est dépendante de l’architecture.
Le deuxième exemple que nous présentons concerne l’implantation de celle-ci pour
les processeurs Blackfin, figure 8.6.
Dans d’anciennes versions de Linux 1, cette fonction appelle memcpy au lieu de copy_
from_user pour lire dans la mémoire du processus. La ligne problématique est préfixée par
/*=>*/. En théorie, si un utilisateur passe un pointeur vers une adresse du noyau à la fonction
ptrace, il pourra lire des données du noyau. L’appel ptrace (PTRACE_PEEKDATA, p, addr)
permet ainsi non seulement de lire les variables du processus p si addr est une adresse dans
l’espace utilisateur (ce qui est le comportement attendu), mais aussi de lire dans l’espace
noyau si addr y pointe (ce qui est un bug de sécurité).
On peut repérer ce bug par simple relecture pour commencer. On commence par remarquer
que l’argument addr, malgré son type long, est en réalité un void * provenant directement
de l’espace utilisateur. C’est en effet le même argument addr de l’appel système ptrace.
Cet argument correspond à l’adresse à lire dans l’espace mémoire du processus. Comme il est
passé à memcpy, aucune vérification n’est faite avant la copie. La valeur pointée par addr sera
copiée, même si elle est en espace noyau.
En annotant correctement les types, on peut donc détecter ce bug : le type correct de
addr est INT @, et celui de memcpy est (INT ∗, INT ∗, INT) → INT ∗. Il est donc impossible de lui
passer cet argument. Remarquons que le type de memcpy en C utilise des pointeurs de type
1. Jusqu’à la version 2.6.28 — ce bug a été corrigé dans le commit 7786ce82 en remplaçant l’appel à memcpy
par un appel à copy_from_user_page.116 CHAPITRE 8. ÉTUDE DE CAS : LE NOYAU LINUX
/* kernel/ptrace.c */
SYSCALL_DEFINE4(ptrace, long, request, long, pid, unsigned long, addr,
unsigned long, data)
{
struct task_struct *child = ptrace_get_task_struct(pid);
/* ... */
long ret = arch_ptrace(child, request, addr, data);
/* ... */
return ret;
}
/* arch/blackfin/kernel/ptrace.c */
long arch_ptrace(struct task_struct *child, long request,
long addr, long data)
{
int ret;
unsigned long __user *datap = (unsigned long __user *)data;
switch (request) {
/* ... */
case PTRACE_PEEKTEXT: {
unsigned long tmp = 0;
int copied;
ret = -EIO;
/* ... */
if (addr >= FIXED_CODE_START
&& addr + sizeof(tmp) <= FIXED_CODE_END) {
/*=>*/ memcpy(&tmp, (const void *)(addr), sizeof(tmp));
copied = sizeof(tmp);
}
/* ... */
ret = put_user(tmp, datap);
break;
/* ... */
}
return ret;
}
FIGURE 8.6 : Implantation de ptrace sur architecture Blackfin8.6. PROCÉDURE EXPÉRIMENTALE 117
void *. Pour les traiter correctement on pourrait utiliser du polymorphisme, mais dans ce
cas précis utiliser le type INT * est suffisant.
Remarque En pratique, le problème de sécurité n’est pas si important. En effet, la copie se
fait sous un test forçant addr à être entre FIXED_CODE_START et FIXED_CODE_END. Cette zone
est incluse en espace utilisateur ; cela empêche donc le problème de fuite de données.
Mais cela reste un problème de sécurité : contrairement à copy_from_user, la fonction
memcpy ne vérifie pas que l’espace utilisateur est chargé en mémoire. Si ce n’est pas le cas, une
faute mémoire sera provoquée dans le noyau. Il s’agit alors d’un déni de service (section 8.3),
qui est tout de même un comportement à empêcher.
8.6 Procédure expérimentale
Pour utiliser notre système de types, plusieurs étapes sont nécessaires en plus de traduire
le noyau Linux en NEWSPEAK.
Afin de réaliser l’analyse, il faut annoter les sources pour créer un environnement initial
(les annotations possibles sont résumées dans un tableau page 102). Plus précisément, pour
chaque source de pointeurs utilisateur, on ajoute un commentaire !npk userptr_fieldp
x f, qui indique que x est un pointeur vers une structure contenant un pointeur utilisateur
dans le champ f. En fait, il unifie le type de x avec {f : t @;...} ∗ où t est une inconnue de
type. Cette annotation est nécessaire car c’est le moyen d’indiquer que la structure contient
un pointeur utilisateur.
Par rapport au code complet présent dans l’annexe A, l’expression calculant value_ptr
est également simplifiée. Dans le code d’origine, info->value est transtypé en unsigned
long puis en uint32_t *. En NEWSPEAK, cela correspond à des opérateurs PtrToInt
et IntToPtr mais, si on les autorise, on casse le typage puisqu’il est alors possible de transformer
n’importe quel type en un autre. De plus, on modifie la définition du type struct
drm_radeon_info pour que son champ value ait pour type uint32_t * plutôt que uint64_t.
En effet, dans ce cas d’étude, cet entier est uniquement utilisé en tant que pointeur au cours
de toute l’exécution.
En ce qui concerne les fonctions de manipulation de pointeurs fournies par le noyau
(get_user, put_user, copy_from_user, copy_to_user, etc.), on ajoute à l’environnement
global leur type correct.
Enfin, on peut lancer l’inférence de type. Ainsi, sur l’exemple de la figure 8.7 (page 118),
on obtient la sortie suivante :
05-drm.c:19#8 - Type clash between :
KPtr (_a15)
UPtr (_a8)
Cela indique qu’on a essayé d’unifier un type de la forme t ∗ avec un type de la forme
t @, en précisant l’emplacement où la dernière unification a échoué (les _aN correspondent
à des inconnues de type). En effet, l’annotation de la ligne 10 donne à data le type {value :
a @;...} ∗, où a est une nouvelle inconnue de type. La ligne 18 donne donc à value_ptr le
type a @. Il y a donc une incompatibilité ligne 19 puisque l’instruction cherche à unifier le
type de value_ptr avec b ∗ où b est une nouvelle inconnue de type. La variable value aurait
alors le type b.118 CHAPITRE 8. ÉTUDE DE CAS : LE NOYAU LINUX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef unsigned long uint32_t;
struct drm_radeon_info {
uint32_t *value;
};
int radeon_info_ioctl(struct drm_device *d, void *data,
struct drm_file *f)
{
/*!npk userptr_fieldp data value*/
struct drm_radeon_info *info;
uint32_t *value_ptr;
uint32_t value;
struct drm_crtc *crtc;
int i, found;
info = data;
value_ptr = info->value;
value = *value_ptr; /* erreur */
return 0;
}
FIGURE 8.7 : Cas d’étude « Radeon » minimisé et annoté
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef unsigned long uint32_t;
struct drm_radeon_info {
uint32_t *value;
};
int radeon_info_ioctl(struct drm_device *d, void *data,
struct drm_file *f)
{
/*!npk userptr_fieldp data value*/
struct drm_radeon_info *info;
uint32_t *value_ptr;
uint32_t value;
struct drm_crtc *crtc;
int i, found;
info = data;
value_ptr = info->value;
if (copy_from_user(&value, value_ptr, sizeof(value)))
return -14;
return 0;
}
FIGURE 8.8 : Cas d’étude « Radeon » minimisé et annoté – version correcte8.6. PROCÉDURE EXPÉRIMENTALE 119
217
218
255
256
257
258
259
260
261
262
342
343
344
345
346
347
348
349
441
long arch_ptrace(struct task_struct *child, long request,
long addr, long data)
{
/* ... */
if (addr >= FIXED_CODE_START
&& addr + sizeof(tmp) <= FIXED_CODE_END) {
#if FIX
copy_from_user_page(0, 0, 0, &tmp,
(const void *)(addr), sizeof(tmp));
#else
memcpy(&tmp, (const void *)(addr), sizeof(tmp));
#endif
copied = sizeof(tmp);
/* ... */
if (addr >= FIXED_CODE_START
&& addr + sizeof(data) <= FIXED_CODE_END) {
#if FIX
copy_to_user_page(0, 0, 0,
(void *)(addr), &data, sizeof(data));
#else
memcpy((void *)(addr), &data, sizeof(data));
#endif
copied = sizeof(data);
/* ... */
}
FIGURE 8.9 : Cas d’étude « Blackfin »
La version correcte minimisée correspond à la figure 8.8. Pour celle-ci, l’inférence se fait
sans erreur. La partie pertinente est la suivante (une explication de la syntaxe est donnée dans
la section 7.3, page 107)) :
(06-drm-ok.c:19#8)^{
Int tmp_cir!0;
(06-drm-ok.c:19#8)^tmp_cir!0 <-
copy_from_user
( (focus32 (&(value) : KPtr (d)) : KPtr (d)): KPtr (d),
(value_ptr_UPtr (d) : UPtr (d)): UPtr (d),
(4 : Int): Int
);
}
En ce qui concerne l’exemple « Blackfin », on commence par isoler la fonction problématique.
Celle-ci utilise de nombreuses constructions propres au noyau. On écrit donc un pré-
ambule permettant de les traiter (définitions de type, etc). Ensuite, il est nécessaire de commenter
certains appels à memcpy pour lesquelles les adresses sont testées dynamiquement
(il n’est donc pas nécessaire d’utiliser les fonctions de copie sûres pour ces sites d’appel). La
figure 8.9 montre le reste de la fonction, c’est-à-dire les parties sensibles.
Dans le cas où FIX vaut 0, la sortie est la suivante :120 CHAPITRE 8. ÉTUDE DE CAS : LE NOYAU LINUX
bf.c:260#32 - Type clash between :
KPtr (Int)
UPtr (_a122)
Et quand FIX vaut 1, le programme annoté est affiché. Les parties correspondantes aux
appels sensibles sont données dans la figurs 8.10.
Conclusion
Après voir décrit l’implantation de notre solution, on a montré comment celle-ci peut
s’appliquer à détecter deux bugs dans le noyau Linux. La première difficulté est de traduire
en NEWSPEAK le code source écrit dans le dialecte GNU C.
Pour chaque bug, on montre que la version originale du code (incluant une erreur de
programmation) ne peut pas être typée, alors que sur la version corrigée on peut inférer des
types compatibles.
Le prototype décrit dans le chapitre 7 peut donc s’adapter à détecter des bugs dans le
noyau Linux. Pour le moment, il nécessite du code annoté, mais des travaux sont en cours
pour permettre de passer automatiquement des portions plus importantes du noyau Linux.
Le principal obstacle est de devoir réécrire certaines parties du code pour supprimer les
constructions non typables.8.6. PROCÉDURE EXPÉRIMENTALE 121
(bf.c:255#3)^guard((! (((coerce[0,4294967295]
(((coerce[0,4294967295] (addr_Int : Int) : Int)
+ (4 : Int)) : Int) : Int) > (1168 : Int)) : Int) : Int));
(bf.c:258#32)^{
Int tmp_cir!7;
(bf.c:258#32)^tmp_cir!7 <-
copy_from_user_page(
(0 : Int): Int,
(0 : Int): Int,
(0 : Int): Int,
(focus32 (&(tmp) : KPtr (Int)) : KPtr (Int)): KPtr (Int),
((ptr) (addr_Int : Int) : UPtr (Int)): UPtr (Int),
(4 : Int): Int);
}
(bf.c:262#4)^copied =(int32) (4 : Int);
...
(bf.c:342#28)^guard((! (((coerce[0,4294967295]
(((coerce[0,4294967295] (addr_Int : Int) : Int)
+ (4 : Int)) : Int) : Int) > (1168 : Int)) : Int) : Int));
(bf.c:345#32)^{
Int tmp_cir!5;
(bf.c:345#32)^tmp_cir!5 <-
copy_to_user_page(
(0 : Int): Int,
(0 : Int): Int,
(0 : Int): Int,
((ptr) (addr_Int : Int) : UPtr (Int)): UPtr (Int),
(focus32 (&(data) : KPtr (Int)) : KPtr (Int)): KPtr (Int),
(4 : Int): Int);
}
(bf.c:349#4)^copied =(int32) (4 : Int);
FIGURE 8.10 : Traduction en NEWSPEAK du cas d’étude « Blackfin »CONCLUSION DE LA PARTIE III
Après avoir décrit notre solution théorique dans la partie II, nous avons présenté ici notre
démarche expérimentale. Dans le chapitre 7, nous avons détaillé l’implantation de notre prototype.
Pour ce faire, nous avons ajouté des étiquettes de type au langage NEWSPEAK et implanté
un algorithme d’inférence de types. Ce prototype est distribué sur [☞3] sous le nom de
ptrtype.
Ensuite, le but du chapitre 8 est d’appliquer notre analyse (à l’aide de ce prototype) sur
le noyau Linux. Après avoir décrit le fonctionnement des appels système sur ce noyau, on
présente deux bugs qui ont touché respectivement un pilote de carte graphique et l’implantation
d’un appel système. Ils sont la manifestation d’un problème de pointeur utilisateur
mal déréférencé dans le noyau, ainsi que décrit dans le chapitre 2. En lançant notre analyse
sur le code présentant un problème, l’erreur est détectée. Au contraire, en la lançant sur le
code après application du correctif, aucune erreur n’est trouvée.
En s’appuyant sur le langage NEWSPEAK, on gagne beaucoup par rapport à d’autres représentations
intermédiaires. Le fait d’avoir un langage avec peu de constructions permet de
ne pas avoir à exprimer plusieurs fois la même règle (par exemple, une fois sur la boucle for
et une autre sur la boucle while).
Un des inconvénients de notre système est que le modèle mémoire utilisé par NEWSPEAK
est assez différent de celui de SAFESPEAK (ainsi que décrit dans le chapitre 4). NEWSPEAK est
en effet prévu pour implanter des analyses précises de valeur reposant sur l’interprétation
abstraite, et nécessite donc un modèle mémoire de plus bas niveau (où on peut créer des
valeurs à partir d’une suite d’octets, par exemple).
Le prototype d’implantation peut évoluer dans deux directions : d’une part, en continuant
à s’appuyer sur NEWSPEAK, on peut réaliser des pré-analyses de typage qui permettent
de guider une analyse de valeurs plus précise, par exemple en choisissant un domaine abstrait
différent en fonction des types de données rencontrés. D’autre part, il est possible de
faire une implantation plus fidèle à SAFESPEAK, qui permette d’ajouter de nouvelles fonctionnalités
plus éloignées de C. Par exemple, un système de régions comme [TJ92] permettrait
de simplifier l’environnement d’exécution en enlevant l’opération de nettoyage mémoire
Cleanup(·). Le système de types peut également être enrichi, pour ajouter par exemple du polymorphisme.
Cela rapprocherait le langage source de Rust. Le chapitre 9 présente quelques
unes de ces extensions possibles.
L’expérimentation, quant à elle, est pour le moment limitée, mais on peut l’étendre à des
domaines de plus en plus importants dans le noyau Linux. Tout d’abord, le module graphique
définit d’autres fonctions implantant des ioctls. Celles-ci reçoivent donc également des pointeurs
utilisateur et sont susceptibles d’être vulnérables à ce genre d’erreurs de programmation.
Ensuite, d’autres modules exposent une interface similaire, à commencer par les autres
pilotes de cartes graphiques. Ceux-ci sont également un terrain sur lequel appliquer cette
analyse.
De manière générale, toutes les interfaces du noyau manipulant des pointeurs utilisateur
gagnent à être analysées. Outre les implantations des ioctls dans chaque pilote et les appels
système, les systèmes de fichiers manipulent aussi de tels pointeurs via leurs opérations de
lecture et d’écriture.
123C H A P I T R E
9
CONCLUSION
On présente ici un résumé des travaux présentés, en commençant par un bilan des contributions
réalisées. On réalise ensuite un tour des aspects posant problème, ou traités de manière
incomplète, en évoquant les travaux possibles pour enrichir l’expressivité de ce système.
9.1 Contributions
Cette thèse comporte 4 contributions principales.
Un langage impératif bien typé Le système de types de C est trop rudimentaire pour permettre
d’obtenir des garanties sur l’exécution des programmes bien typés. En interdisant
certaines constructions dangereuses et en annotant certaines autres, nous avons isolé un
langage impératif bien typable, SAFESPEAK, pour lequel on peut définir un système de types
sûr.
Une sémantique basée sur les lentilles Une des particularités de SAFESPEAK est qu’il utilise
un état mémoire structuré, modélisant les cadres de piles présents dans le langage. Pour
décrire la sémantique des accès mémoire, nous utilisons le concept de lentilles issues de la
programmation fonctionnelle et des systèmes de bases de données. Cela permet de définir
de manière déclarative la modification en profondeur de valeurs dans la mémoire, sans avoir
à distinguer le cas de la lecture et celui de l’écriture.
Un système de types abstraits En partant de ce système de types, on a décrit une extension
permettant de créer des pointeurs pour lesquels l’opération de déréférencement est restreinte
à certaines fonctions. Dans le contexte d’un noyau de système d’exploitation, cette
restriction permet de vérifier statiquement qu’à aucun moment le noyau ne déréférence un
pointeur dont la valeur est contrôlée par l’espace utilisateur, évitant ainsi un problème de
sécurité. Cette approche peut s’étendre à d’autres classes de problèmes comme par exemple
éviter l’utilisation de certaines opérations sur les types entiers lorsqu’ils sont utilisés comme
identificateurs ou masque de bits.
Un prototype d’analyseur statique Les analyses de typage ici décrites ont été implantées
sous forme d’un prototype d’analyseur statique distribué avec le langage NEWSPEAK, développé
par EADS. Le choix de NEWSPEAK pour l’implantation demande d’adapter les règles de
125126 CHAPITRE 9. CONCLUSION
typage, mais il permet de réutiliser un traducteur existant et à l’entreprise de profiter des ré-
sultats. Ce prototype permet d’une part de vérifier la propriété d’isolation des appels système
sur du code C existant, et d’autre part fournit une base saine pour implanter d’autres analyses
de typage sur le langage NEWSPEAK. Ce prototype a été utilisé pour confirmer l’existence de
deux bugs dans le noyau Linux, ce qui permet de valider l’approche : il est possible de vérifier
du code de production à l’aide de techniques de typage. Des travaux d’expérimentation sont
en cours afin d’analyser de plus grandes parties du noyau.
9.2 Différences avec C
SAFESPEAK a été construit pour pouvoir ajouter un système de types à un langage proche
de C. Ces deux langages diffèrent donc sur certains points. On détaille ici ces différences et,
selon les cas, comment les combler ou pourquoi cela est impossible de manière inhérente.
Types numériques En C, on dispose de plusieurs types entiers, pouvant avoir plusieurs
tailles et être signés ou non signés, ainsi que des types flottants qui diffèrent par leur taille.
Au contraire, en SAFESPEAK on ne conserve qu’un seul type d’entier et un seul type de flottant.
La raison pour cela est que nous ne nous intéressons pas du tout aux problématiques de
sémantique arithmétique : les débordements, dénormalisations, etc, sont supposés ne pas
arriver.
Il est possible d’étendre le système de types de SAFESPEAK pour ajouter tous ces nouveaux
types. La traduction depuis NEWSPEAK insère déjà des opérateurs de transtypage pour
lesquels il est facile de donner une sémantique (pouvant lever une erreur en cas de débordement,
comme en Ada) et un typage. Les littéraux numériques peuvent poser problème, puisqu’ils
deviennent alors polymorphes. Une solution peut être de leur donner le plus grand type
entier et d’insérer un opérateur de transtypage à chaque littéral. Haskell utilise une solution
similaire : les littéraux entiers ont le type de précision arbitraire Integer et sont convertis
dans le bon type en appelant la fonction fromInteger du type synthétisé à partir de l’environnement.
Transtypage et unions Puisque l’approche retenue est basée sur le typage statique, il est
impossible de capturer de nombreuses constructions qui sont permises, ou même idiomatiques,
en C : les unions, les conversions de types (explicites ou implicites) et le type punning
(défini ci-dessous). Les deux premières sont équivalentes. Bien qu’on puisse remplacer
chaque conversion explicite d’un type t1 vers un type t2 par l’appel à une fonction castt1,t2 ,
on ajoute alors un « trou » dans le système de types. Cette fonction devrait en effet être typée
(t1) → t2, autrement dit le type « maudit » α → β de Obj.magic en OCaml ou unsafeCoerce
en Haskell.
Le type punning consiste à modifier directement la suite de bits de certaines données
pour la manipuler d’une manière efficace. Par exemple, il est commun de définir un ensemble
de macros pour accéder à la mantisse et à l’exposant de flottants IEEE754. Ceci peut être fait
avec des unions ou des masques de bits.
Dans de tels cas, le typage statique est bien sûr impossible. Pour traiter ces cas, il faudrait
encapsuler la manipulation dans une fonction et y ajouter une information de type explicite,
comme float_exponent : (FLOAT) → INT.
Pour ces conversions de types, on distingue en fait plusieurs cas : les conversions entre
types numériques, entre types pointeurs, ou entre un type entier et un type pointeur.9.2. DIFFÉRENCES AVEC C 127
Le premier ne pose pas de problème : il est toujours possible de donner une sémantique à
une conversion entre deux types numériques, quitte à détecter les cas où il faut signaler une
erreur à l’exécution (comme en cas de débordement).
Le deuxième non plus n’est pas un problème en soi : une conversion entre deux types
pointeurs revient à convertir entre les types pointés (il faut bien sûr interdire les conversions
entre pointeurs noyau et utilisateur).
Le vrai problème provient des conversions entre entiers et pointeurs, qui sont des données
fondamentalement différentes. Le même problème se pose d’ailleurs si on cherche à
convertir une fonction en entier ou en pointeur, même si les raisons valables pour faire cela
sont moins nombreuses. Si on s’en tient aux conversions entre entiers et pointeurs, une manière
naïve de typer ces opérations est :
Γ � e : t ∗
Γ � (INT) e : INT
(PTRINT-BAD)
Γ � e : INT
Γ � (PTR) e : t ∗ (INTPTR-BAD)
Tout d’abord, cela pose problème car il est alors possible de créer une fonction pouvant
convertir n’importe quel type pointeur en n’importe quel autre type pointeur :
� fun(p){RETURN((PTR) (INT) p)} : (ta ∗) → tb ∗
Si on crée une variable du type ta, prend son adresse, la convertit à l’aide de cette fonction,
puis déréférence le résultat, on obtient une valeur du type tb (remarquons que ce genre
d’opération est tout à fait possible en C).
Outre ce problème de typage, il faudrait pouvoir donner une sémantique à ces opérations.
Convertir un pointeur en entier revient à spécifier l’environnement d’exécution, c’est-à-dire
qu’il faut une fonction de placement en mémoire beaucoup plus précise que notre modèle
mémoire actuel. Celle-ci dépend de beaucoup de paramètres : dans quel sens croit la pile,
quelle est la taille des types, etc.
La conversion dans le sens inverse, d’entier vers pointeur, est encore plus complexe. Entre
autres, cela suppose qu’on puisse retrouver la taille des valeurs à partir de leur adresse. Dans
de nombreux langages, on résout ce problème en stockant la taille de chaque valeur avec elle.
Mais cela fait s’éloigner du modèle mémoire de C, où le déréférencement porte sur une
adresse mais également sur une taille (portée implicitement par le type du pointeur). Le langage
NEWSPEAK conserve d’ailleurs cette distinction, que nous avons éliminée dans SAFESPEAK.
Il y a une incompatibilité entre ces deux approches : dans le cas de C (et de NEWSPEAK),
on laisse le programmeur gérer l’organisation de la mémoire alors qu’avec SAFESPEAK ces
choix sont faits par le langage. En contrepartie, cela permet d’avoir d’assurer la sûreté du typage.
Environnement d’exécution La sémantique opérationnelle utilise un environnement
d’exécution pour certains cas. Contrairement à C, les débordements de tampon et les déréfé-
rencements de pointeurs sont vérifiés dynamiquement. Mais ce n’est pas une caractéristique
cruciale de cette approche : en effet, si on suppose que les programmes que l’on analyse ne
comportent pas de telles erreurs de programmation, on peut désactiver ces vérifications et le
reste des propriétés est toujours valable.
On repose sur l’environnement d’exécution à un endroit plus problématique. À la sortie
de chaque portée (au retour d’une fonction et après la portée d’une variable locale déclarée),
on parcourt la mémoire à la recherche des pointeurs référençant les variables qui ne sont plus128 CHAPITRE 9. CONCLUSION
valides. Supprimer ce test rend l’analyse incorrecte, car il est alors possible de faire référence
à une variable avec un type différent.
Si on peut avoir une garantie statique que les adresses des variables locales ne seront plus
accessibles au retour d’une fonction, alors on peut supprimer cette étape de nettoyage. Cette
garantie peut être obtenue avec une analyse statique préalable. Par exemple les régions [TJ92]
peuvent être utilisées à cet effet : en plus de donner un type à chaque expression, on calcule
statiquement la zone mémoire dans laquelle cette valeur sera allouée. Cela correspond à un
ramasse-miette réalisé statiquement.
Flot de contrôle Dans le langage C, en plus des boucles et de l’alternative, on peut sauter
d’une instruction à l’autre au sein d’une fonction à l’aide de la construction goto. Pour pouvoir
traiter ces cas, il est possible de transformer ces sauts d’un programme vers des simples
boucles. Cette réécriture peut être coûteuse puisqu’elle peut introduire des variables booléennes
et dupliquer du code. En pratique, c’est d’ailleurs ce qui est fait dans l’implantation
puisque cette transformation est réalisée par C2NEWSPEAK.
Dans le noyau Linux, il est courant d’utiliser les sauts pour factoriser la libération de ressources
à la fin d’une fonction. Il est d’ailleurs possible d’utiliser l’outil Coccinelle pour donner
cette forme à du code utilisant un autre style de structures de contrôle [SLM11]. On peut
imaginer qu’il est possible de l’utiliser pour faire la conversion inverse.
En plus de ces sauts locaux, le langage C contient une manière de sauvegarder un état
d’exécution et d’y sauter, même entre deux fonctions : ce sont respectivement les constructions
setjmp et longjmp. Elles sont très puissantes puisqu’elles permettent d’exprimer de
nouvelles structures de contrôle. Il s’agit de formes légères de continuations où la pile reste
commune. Cette fonctionnalité peut servir par exemple à implanter des exceptions ou des
coroutines.
Avec l’interprète du chapitre 4, il n’est pas possible de donner une sémantique à ces
constructions. Une des manières de faire est de modifier les états de l’interprète : au lieu de
retenir l’instruction à évaluer avec 〈i,m〉, on retient la continuation complète : 〈k,m〉. Pour
faciliter ce changement, on peut tout d’abord passer à une sémantique monadique (ainsi
qu’évoqué dans la conclusion de la partie II, page 95) puis ajouter les continuations à la monade
sous-jacente.
En pratique, il est rare de trouver ces constructions plus avancées dans du code noyau ou
embarqué, donc ce manque n’a pas beaucoup d’impact. De plus, cela permet une présentation
plus simple et accessible.
Allocation dynamique La plupart des programmes, et le noyau Linux en particulier, utilisent
la notion d’allocation dynamique de mémoire. C’est une manière de créer dynamiquement
une zone de mémoire qui restera accessible après l’exécution de la fonction courante.
Cette mémoire pourra être libérée à l’aide d’une fonction dédiée. Dans l’espace utilisateur,
les programmes peuvent utiliser les fonctions malloc(), calloc() et realloc() pour allouer
des zones de mémoire et free() pour les libérer. Dans le noyau Linux, ces fonctions existent
sous la forme de kmalloc(), kfree(), etc. Une explication détaillée de ces mécanismes peut
être trouvée dans [Gor04].
Ces fonctions manipulent les données en tant que zones mémoires opaques, en renvoyant
un pointeur vers une zone mémoire d’un nombre d’octets donnés. Cela présuppose
un modèle mémoire de plus bas niveau. Pour se rapprocher de la sémantique de SAFESPEAK,
une manière de faire est de définir un opérateur de plus haut niveau prenant une expression
et retournant l’adresse d’une cellule mémoire contenant cette valeur (la taille de chaque9.3. PERSPECTIVES 129
valeur fait partie de celle-ci), ou NULL si l’allocation échoue. Le typage est alors direct (on
suppose que FREE(e) est une instruction :
Γ � e : t
Γ � NEW(e) : t ∗ (NEW )
Γ � e : t ∗
Γ � FREE(e)
(FREE)
En ce qui concerne l’exécution, on peut ajouter une troisième composante aux états mé-
moire : m = (s, g ,h) où h est une liste d’association entre des identifiants uniques et des
valeurs. Chaque allocation dynamique crée une nouvelle clef entière et met à jour h. La libération
de mémoire est en revanche problématique parce qu’il faut faire confiance au programmeur
pour ne pas accéder aux zones mémoires libérées, ni libérer deux fois la même
zone mémoire. Il est aussi possible d’obtenir cette garantie avec une analyse préalable. Par
exemple, il est possible ici encore d’utiliser une analyse basée sur les régions pour vérifier
l’absence de pointeurs fous [DDMP10].
9.3 Perspectives
L’importance des logiciels grandit par deux effets : d’une part, ils sont présents dans de
plus en plus d’appareils et, d’autre part, leur taille est de plus en plus importante. En une
journée, entre les appareils dédiés au calcul, à la communication, au multimédia et au transport,
on est facilement exposé au fonctionnement de plus d’une dizaine de millions de lignes
de code. Il donc primordial de vérifier que ces logiciels ne peuvent pas être détournés de leur
utilisation prévue. Dans le cas de logiciels avioniques ou militaires, les conséquences peuvent
en effet être catastrophiques. C’est dans ce contexte industriel que ce travail a été motivé et
réalisé.
Au cœur de la plupart de ces systèmes informatiques se trouve un noyau qui abstrait les
détails du matériel pour fournir aux programmes des abstractions sûres, permettant de protéger
les données sensibles contenues dans ce système. Puisqu’une simple erreur de programmation
peut briser cette isolation, on voit pourquoi la vérification est si importante.
Dans ce but, les systèmes de types sont des outils bien connus de programmeurs. Même
dans les langages peu typés comme C, les compilateurs aident de plus en plus les programmeurs
à trouver des erreurs de programmation. De très nombreuses analyses peuvent être
faites rien qu’en classant les expressions selon le genre de valeurs qu’elles créent à l’exécution
— c’est la définition que donne Benjamin C. Pierce d’un système de types [Pie02].
L’utilisation d’un système de types comme analyseur statique léger est donc efficace. Pour
des propriétés qui ne dépendent pas de la valeur des expressions, mais uniquement de leur
forme, c’est d’ailleurs la solution à préférer. En effet, nous avons montré qu’elle est simple à
mettre en œuvre et rapide à exécuter.
On peut se poser la question suivante : pourquoi a-t-on besoin d’une analyse statique dé-
diée, plutôt que de passer par le langage C lui-même ? Le problème vient du fait que celui-ci
considère que les types définissent une représentation en mémoire sans guère plus d’information.
On peut définir de nouveaux noms pour un type, mais l’ancien et le nouveau sont
alors compatibles. En un mot il est impossible de distinguer le rôle d’un type (son intention)
de sa représentation (son extension).
Ici, nous avons proposé une solution au problème de pointeurs utilisateur en introduisant
un type ayant la même représentation que les pointeurs classiques, mais pour lequel
l’ensemble des opérations est différent : c’est un type opaque. Cela suffit déjà à détecter des
erreurs de programmation.130 CHAPITRE 9. CONCLUSION
Si on ajoutait cette construction au langage, on pourrait définir de nouveaux types partageant
la représentation d’un type C existant, mais qui ne soit pas compatible avec le type
d’origine. Avec cette fonctionnalité dans un langage, non seulement on peut détecter d’autres
classes de problèmes, mais surtout on laisse le programmeur définir de nouvelles analyses
lui-même en modélisant les problèmes concrets par des types.Annexes
131A N N E X E
A
MODULE RADEON KMS
On inclut ici le code analysé dans le chapitre 8. On inclut à la suite le contexte nécessaire
pour comprendre ce code.
/* from drivers/gpu/drm/radeon/radeon_kms.c */
int radeon_info_ioctl(struct drm_device *dev, void *data, struct drm_file *filp)
{
struct radeon_device *rdev = dev->dev_private;
struct drm_radeon_info *info;
struct radeon_mode_info *minfo = &rdev->mode_info;
uint32_t *value_ptr;
uint32_t value;
struct drm_crtc *crtc;
int i, found;
info = data;
value_ptr = (uint32_t *)((unsigned long)info->value);
value = *value_ptr;
switch (info->request) {
case RADEON_INFO_DEVICE_ID:
value = dev->pci_device;
break;
case RADEON_INFO_NUM_GB_PIPES:
value = rdev->num_gb_pipes;
break;
case RADEON_INFO_NUM_Z_PIPES:
value = rdev->num_z_pipes;
break;
case RADEON_INFO_ACCEL_WORKING:
/* xf86-video-ati 6.13.0 relies on this being false for evergreen */
if ((rdev->family >= CHIP_CEDAR) && (rdev->family <= CHIP_HEMLOCK))
value = false;
else
value = rdev->accel_working;
break;
case RADEON_INFO_CRTC_FROM_ID:
for (i = 0, found = 0; i < rdev->num_crtc; i++) {
crtc = (struct drm_crtc *)minfo->crtcs[i];
if (crtc && crtc->base.id == value) {
struct radeon_crtc *radeon_crtc = to_radeon_crtc(crtc);
value = radeon_crtc->crtc_id;
133134 ANNEXE A. MODULE RADEON KMS
found = 1;
break;
}
}
if (!found) {
DRM_DEBUG_KMS("unknown crtc id %d\n", value);
return -EINVAL;
}
break;
case RADEON_INFO_ACCEL_WORKING2:
value = rdev->accel_working;
break;
case RADEON_INFO_TILING_CONFIG:
if (rdev->family >= CHIP_CEDAR)
value = rdev->config.evergreen.tile_config;
else if (rdev->family >= CHIP_RV770)
value = rdev->config.rv770.tile_config;
else if (rdev->family >= CHIP_R600)
value = rdev->config.r600.tile_config;
else {
DRM_DEBUG_KMS("tiling config is r6xx+ only!\n");
return -EINVAL;
}
case RADEON_INFO_WANT_HYPERZ:
mutex_lock(&dev->struct_mutex);
if (rdev->hyperz_filp)
value = 0;
else {
rdev->hyperz_filp = filp;
value = 1;
}
mutex_unlock(&dev->struct_mutex);
break;
default:
DRM_DEBUG_KMS("Invalid request %d\n", info->request);
return -EINVAL;
}
if (DRM_COPY_TO_USER(value_ptr, &value, sizeof(uint32_t))) {
DRM_ERROR("copy_to_user\n");
return -EFAULT;
}
return 0;
}
/* from include/drm/radeon_drm.h */
struct drm_radeon_info {
uint32_t request;
uint32_t pad;
uint64_t value;
};
/* from drivers/gpu/drm/radeon/radeon_kms.c */
struct drm_ioctl_desc radeon_ioctls_kms[] = {
/* KMS */
DRM_IOCTL_DEF(DRM_RADEON_INFO, radeon_info_ioctl, DRM_AUTH|DRM_UNLOCKED)135
};
/* from drivers/gpu/drm/radeon/radeon_drv.c */
static struct drm_driver kms_driver = {
.driver_features =
DRIVER_USE_AGP | DRIVER_USE_MTRR | DRIVER_PCI_DMA | DRIVER_SG |
DRIVER_HAVE_IRQ | DRIVER_HAVE_DMA | DRIVER_IRQ_SHARED | DRIVER_GEM,
.dev_priv_size = 0,
.ioctls = radeon_ioctls_kms,
.name = "radeon",
.desc = "ATI Radeon",
.date = "20080528",
.major = 2,
.minor = 6,
.patchlevel = 0,
};
/* from drivers/gpu/drm/drm_drv.c */
int drm_init(struct drm_driver *driver)
{
DRM_DEBUG("\n");
INIT_LIST_HEAD(&driver->device_list);
if (driver->driver_features & DRIVER_USE_PLATFORM_DEVICE)
return drm_platform_init(driver);
else
return drm_pci_init(driver);
}A N N E X E
B
SYNTAXE ET RÈGLES D’ÉVALUATION
On rappelle ici pour référence la syntaxe de SAFESPEAK, ainsi que sa sémantique d’évaluation.
Les règles sont décrites dans les chapitres 4 et 6.
B.1 Syntaxe des expressions
Constantes c ::= n Entier
| d Flottant
| NULL Pointeur nul
| ( ) Valeur unité
Expressions e ::= c Constante
| � e Opération unaire
| e � e Opération binaire
| l v Accès mémoire
| l v ← e Affectation
| &l v Pointeur
| f Fonction
| e(e1,...,en) Appel de fonction
| {l1 : e1;...;ln : en} Structure
| [e1;...;en] Tableau
Valeurs
gauches
l v ::= x Variable
| l v.lS Accès à un champ
| l v[e] Accès à un élément
| ∗e Déréférencement
Fonctions f ::= fun(x1,...,xn){i} Arguments, corps
137138 ANNEXE B. SYNTAXE ET RÈGLES D’ÉVALUATION
B.2 Syntaxe des instructions
Instructions i ::= PASS Instruction vide
| i;i Séquence
| e Expression
| DECL x = e IN{i} Déclaration de variable
| IF(e){i}ELSE{i} Alternative
| WHILE(e){i} Boucle
| RETURN(e) Retour de fonction
Phrases p ::= x = e Variable globale
| e Évaluation d’expression
Programme P ::= (p1,...,pn) Phrases
B.3 Syntaxe des opérateurs
Opérateurs
binaires
� ::= +,−,×,/,% Arithmétique entière
| +.,−.,×.,/. Arithmétique flottante
| +p,−p Arithmétique de pointeurs
| ≤,≥,<,> Comparaison sur les entiers
| ≤ .,≥ .,< .,> . Comparaison sur les flottants
| =,�= Tests d’égalité
| &,|,^ Opérateurs bit à bit
| &&,|| Opérateurs logiques
| �,� Décalages
Opérateurs
unaires
� ::= +,− Arithmétique entière
| +.,−. Arithmétique flottante
| ∼ Négation bit à bit
| ! Négation logiqueB.4. CONTEXTES D’ÉVALUATION 139
B.4 Contextes d’évaluation
Contextes C ::= •
| C � e
| v � C
| � C
| & C
| C ← e
| ϕ ←C
| {l1 : v1;...;li :C;...;ln : en}
| [v1;...;C;...;en]
| C(e1,...,en)
| f (v1,...,C,...,en)
| C.lS
| C[e]
| ϕ[C]
| ∗ C
| C;i
| IF(C){i1}ELSE{i2}
| RETURN(C)
| DECL x = C IN{i}
B.5 Règles d’évaluation des erreurs
Ξ → Ω
〈Ω,m〉 → Ω
(EXP-ERR)
〈e,m〉 → Ω
〈C�e�,m〉 → Ω
(EVAL-ERR)
〈i,m〉 → Ω
〈DECL x = v IN{i},m〉 → Ω
(DECL-ERR)
m� = Push(m, ((a1 �→ v1),..., (an �→ vn))) 〈i,m�
〉 → Ω
〈fun(a1,...,an){i}(v1,..., vn),m〉 → Ω
(EXP-CALL-ERR)140 ANNEXE B. SYNTAXE ET RÈGLES D’ÉVALUATION
B.6 Règles d’évaluation des valeurs gauches et expressions
〈l v,m〉 → 〈ϕ,m〉
a = Lookup(x,m)
〈x,m〉 → 〈a,m〉
(PHI-VAR)
v = &� ϕ
〈∗ v,m〉 → 〈ϕ,m〉
(EXP-DEREF)
v = N
�ULL
〈∗ v,m〉 → Ωp t r
(EXP-DEREF-NULL)
〈ϕ.lS,m〉 → 〈ϕ�.l,m〉
(PHI-STRUCT)
〈ϕ[n],m〉 → 〈ϕ[
�n],m〉
(PHI-ARRAY)
〈e,m〉 → 〈v,m〉
〈c,m〉 → 〈c�,m〉
(EXP-CST)
〈f ,m〉 → 〈f
�,m〉
(EXP-FUN)
〈ϕ,m〉 → 〈m[ϕ]Φ,m〉
(EXP-LV )
〈� v,m〉 → 〈�� v,m〉
(EXP-UNOP)
〈v1 � v2,m〉 → 〈v1 �� v2,m〉
(EXP-BINOP)
〈& ϕ,m〉 → 〈&� ϕ,m〉
(EXP-ADDR)
〈ϕ ← v,m〉 → 〈v,m[ϕ ← v]Φ〉
(EXP-SET)
〈{l1 : v1;...;ln : vn},m〉 → 〈{l1 : v�1;...;ln : vn},m〉
(EXP-STRUCT)
〈[v1;...; vn],m〉 → 〈[v�1;...; vn],m〉
(EXP-ARRAY)
m� = Cleanup(m) v� = CleanV|m|(v)
〈fun(a1,...,an){RETURN(v)}(v1,..., vn),m〉 → 〈v�
,m�
〉
(EXP-CALL-RETURN)
〈e,m〉 → 〈e�
,m�
〉
〈i,m〉 → 〈i
�
,m�
〉
〈C�i�,m〉 → 〈C�i
�
�,m�
〉
(CTX)
m1 = Push(m0, ((a1 �→ v1),..., (an �→ vn)))
〈i,m1〉 → 〈i
�
,m2〉 ∀i ∈ [1;n], v�
i = m2[(|m2|,ai)]A m3 = Pop(m2)
〈fun(a1,...,an){i}(v1,..., vn),m0〉 → 〈fun(a1,...,an){i
�
}(v�
1,..., v�
n),m3〉
(EXP-CALL-CTX)B.7. RÈGLES D’ÉVALUATION DES INSTRUCTIONS, PHRASES ET PROGRAMMES 141
B.7 Règles d’évaluation des instructions, phrases et programmes
〈i,m〉 → 〈i�
,m〉
〈(PASS;i),m〉 → 〈i,m〉
(SEQ)
〈v,m〉 → 〈PASS,m〉
(EXP)
m� = CleanVar(m − x, (|m|,x))
〈DECL x = v IN{PASS},m〉 → 〈PASS,m�
〉
(DECL-PASS)
m� = CleanVar(m − x, (|m|,x)) v�� = CleanVarV(v�
, (|m|,x))
〈DECL x = v IN{RETURN(v�
)},m〉 → 〈RETURN(v��),m�
〉
(DECL-RETURN)
m� = Extend(m,x �→ v)
〈i,m�
〉 → 〈i
�
,m��〉 v� = m��[(|m��|,x)]A m��� = m�� − x
〈DECL x = v IN{i},m〉 → 〈DECL x = v� IN{i
�
},m���〉
(DECL-CTX)
〈IF(0){it }ELSE{if },m〉 → 〈if ,m〉
(IF-FALSE)
v �= 0
〈IF(v){it }ELSE{if },m〉 → 〈it ,m〉
(IF-TRUE)
〈WHILE(e){i},m〉 → 〈IF(e){i;WHILE(e){i}}ELSE{PASS},m〉
(WHILE)
〈RETURN(v);i,m〉 → 〈RETURN(v),m〉
(RETURN)
m � p → m�
〈e,m〉 → 〈v,m�
〉
m � e → m� (ET-EXP)
〈e,m〉 → 〈v,m�
〉 m� = (s, g ) m�� = (s, (x �→ v) :: g )
m � x = e → m�� (ET-VAR)
� P →∗ m
([ ], [ ]) � p1 → m1 m1 � p2 → m2 ... mn−1 � pn → mn
� p1,...,pn →∗ m
(PROG)142 ANNEXE B. SYNTAXE ET RÈGLES D’ÉVALUATION
B.8 Règles d’évaluation des extensions noyau
〈♦ ϕ,m〉 → 〈& ( � ♦� ϕ),m〉
(PHI-USER)
v = m[ϕs]Φ m� = m[ϕd ← v]Φ
〈copy_from_user(&� ϕd ,& ( � ♦� ϕs)),m〉 → 〈0,m�
〉
(USER-GET-OK)
� ϕs.ϕ = ♦� ϕs
〈copy_from_user(&� ϕd ,&� ϕ),m〉 → 〈−14,m〉
(USER-GET-ERR)
v = m[ϕs]Φ m� = m[ϕd ← v]Φ
〈copy_to_user(& ( � ♦� ϕd ),&� ϕs),m〉 → 〈0,m�
〉
(USER-PUT-OK)
� ϕd .ϕ = ♦� ϕd
〈copy_to_user(&� ϕ,&� ϕs),m〉 → 〈−14,m〉
(USER-PUT-ERR)A N N E X E
C
RÈGLES DE TYPAGE
On rappelle ici l’ensemble des règles de typage décrites dans les chapitres 5 et 6.
C.1 Règles de typage des constantes et valeurs gauches
Γ � c : t
Γ � n : INT
(CST-INT)
Γ � d : FLOAT
(CST-FLOAT)
Γ � NULL : t ∗ (CST-NULL)
Γ � ( ) : UNIT
(CST-UNIT)
Γ � l v : t
x : t ∈ Γ
Γ � x : t
(LV-VAR)
Γ � e : t ∗
Γ � ∗e : t
(LV-DEREF)
Γ � e : INT Γ � l v : t[ ]
Γ � l v[e] : t
(LV-INDEX)
(l,t) ∈ S Γ � l v : S
Γ � l v.lS : t
(LV-FIELD)
143144 ANNEXE C. RÈGLES DE TYPAGE
C.2 Règles de typage des opérateurs
Γ � � e : t
Γ � e : INT
Γ � +e : INT
(UNOP-PLUS-INT)
Γ � e : FLOAT
Γ � +.e : FLOAT
(UNOP-PLUS-FLOAT)
Γ � e : INT
Γ � −e : INT
(UNOP-MINUS-INT)
Γ � e : FLOAT
Γ � −.e : FLOAT
(UNOP-MINUS-FLOAT)
� ∈ {∼,!} Γ � e : INT
Γ � � e : INT
(UNOP-NOT)
Γ � e1 � e2 : t
� ∈ {+,−,×,/,&,|,^,&&,||,�,�,≤,≥,<,>} Γ � e1 : INT Γ � e2 : INT
Γ � e1 � e2 : INT
(OP-INT)
� ∈ {+.,−.,×.,/.,≤ .,≥ .,< .,> .} Γ � e1 : FLOAT Γ � e2 : FLOAT
Γ � e1 � e2 : FLOAT
(OP-FLOAT)
� ∈ {=,�=} Γ � e1 : t Γ � e2 : t EQ(t)
Γ � e1 � e2 : INT
(OP-EQ)
� ∈ {+p,−p} Γ � e1 : t ∗ Γ � e2 : INT
Γ � e1 � e2 : t ∗ (PTR-ARITH)
EQ(t)
t ∈ {INT, FLOAT}
EQ(t)
(EQ-NUM)
EQ(t ∗)
(EQ-PTR)
EQ(t)
EQ(t[ ])
(EQ-ARRAY)
∀i ∈ [1;n].EQ(ti)
EQ({l1 : t1;...ln : tn})
(EQ-STRUCT)C.3. RÈGLES DE TYPAGE DES EXPRESSIONS ET INSTRUCTIONS 145
C.3 Règles de typage des expressions et instructions
Γ � e : t
Γ � l v : t
Γ � &l v : t ∗ (ADDR)
∀i ∈ [1;n],Γ � ei : ti
Γ � {l1 : e1;...;ln : en} : {l1 : t1;...;ln : tn}
(STRUCT)
Γ � e : (t1,...,tn) → t ∀i ∈ [1;n],Γ � ei : ti
Γ � e(e1,...,en) : t
(CALL)
Γ � l v : t Γ � e : t
Γ � l v ← e : t
(SET)
∀i ∈ [1;n],Γ � ei : t
Γ � [e1;...;en] : t[ ]
(ARRAY)
Γ = (ΓG ,ΓL) Γ� = (ΓG , [a1 : t1;...;an : tn;R : t]) Γ� � i
Γ � fun(a1,...,an){i} : (t1,...,tn) → t
(FUN)
Γ � i
Γ � PASS
(PASS)
Γ � i1 Γ � i2
Γ � i1;i2
(SEQ)
Γ � e : t
Γ � e
(EXP)
Γ � e : t Γ,local x : t � i
Γ � DECL x = e IN{i}
(DECL)
Γ � e : INT Γ � i1 Γ � i2
Γ � IF(e){i1}ELSE{i2}
(IF)
Γ � e : INT Γ � i
Γ � WHILE(e){i}
(WHILE)
R : t ∈ Γ Γ � e : t
Γ � RETURN(e)
(RETURN)
Γ � p → Γ�
Γ � e : t
Γ � e → Γ
(T-EXP)
Γ � e : t Γ� = Γ, global x : t
Γ � x = e → Γ� (T-VAR)
Γ � P
[ ] � p1 → Γ1 Γ1 � p2 → Γ2 ... Γn−1 � pn → Γn
� p1,...,pn
(PROG)146 ANNEXE C. RÈGLES DE TYPAGE
C.4 Règles de typage des valeurs
m � v : τ
m � n : INT
(S-INT)
m � d : FLOAT
(S-FLOAT)
m � ( ) : UNIT
(S-UNIT)
m � NULL : τ ∗ (S-NULL)
m �Φ ϕ : τ
m � &� ϕ : τ ∗ (S-PTR)
∀i ∈ [1;n].m � vi : τ
m � [v�1;...; vn] : τ[ ]
(S-ARRAY)
∀i ∈ [1;n].m � vi : τi
m � {l1 : v�1;...;ln : vn} : {l1 : τ1;...;ln : τn}
(S-STRUCT)
m � fun(x1,...,xn){i} : FUNn
(S-FUN)
C.5 Règles de typage des extensions noyau
Γ � l v : t
Γ � ♦ l v : t @
(ADDR-USER)
Γ � ed : t ∗ Γ � es : t @
Γ � copy_from_user(ed ,es) : INT
(USER-GET)
Γ � ed : t @ Γ � es : t ∗
Γ � copy_to_user(ed ,es) : INT
(USER-PUT)A N N E X E
D
PREUVES
On présente ici les preuves de certains résultats établis dans le manuscrit : le caractère
bien fondé de la composition de deux lentilles, et les théorèmes de sûreté du typage.
D.1 Composition de lentilles
Démonstration. On cherche à prouver que, si L1 ∈ LENSA,B et L2 ∈ LENSB,C , alors L =
L1≫L2 ∈ LENSA,C (≫est la composition de lentilles, définie page 39).
Il suffit pour cela d’établir les trois propriétés caractéristiques qui définissent les lentilles :
PUTPUT, GETPUT et PUTGET. Cela est essentiellement calculatoire : on utilise la définition de
≫et les propriétés caractéristiques sur L1 et L2.
PutPut
putL (a�
,putL (a, r ))
= putL (a�
,putL1 (putL2 (a, getL1
(r )), r ))
{ définition de putL }
= putL1 (putL2 (a�
, getL1
(putL1 (putL2 (a, getL1
(r )), r ))),putL1 (putL2 (a, getL1
(r )), r ))
{ définition de putL }
= putL1 (putL2 (a�
,putL2 (a, getL1
(r ))),putL1 (putL2 (a, getL1
(r )), r ))
{ GETPUT sur L1 }
= putL1 (putL2 (a�
, getL1
(r )),putL1 (putL2 (a, getL1
(r )), r ))
{ PUTPUT sur L2 }
= putL1 (putL2 (a�
, getL1
(r )), r )
{ PUTPUT sur L1 }
= putL (a�
, r )
{ définition de≫}
147148 ANNEXE D. PREUVES
GetPut
putL (getL (r ), r ) = putL (getL2
(getL1
(r )), r ) { définition de getL }
= putL1 (putL2 (getL2
(getL1
(r )), getL1
(r )), r ) { définition de putL }
= putL1 (getL1
(r ), r ) { GETPUT sur L2 }
= r { GETPUT sur L1 }
PutGet
getL (putL (a, r )) = getL2
(getL1
(putL (a, r ))) { définition de getL }
= getL2
(getL1
(putL1 (putL2 (a, getL1
(r )), r ))) { définition de putL }
= getL2
(putL2 (a, getL1
(r ))) { PUTGET sur L1 }
= a { PUTGET sur L2 }
D.2 Progrès
On rappelle l’énoncé du théorème 5.1.
Théorème D.1 (Progrès). Supposons que Γ � i. Soit m un état mémoire tel que Γ � m.
Alors l’un des cas suivants est vrai :
• i = PASS
• ∃v,i = RETURN(v)
• ∃(i�
,m�
),〈i,m〉 → 〈i�
,m�
〉
• ∃Ω ∈ {Ωd i v ,Ωar r ay ,Ωp t r },〈i,m〉 → Ω
� � �
Supposons que Γ � e : t. Soit m un état mémoire tel que Γ � m. Alors l’un des cas suivant
est vrai :
• ∃v �= Ω,e = v
• ∃(e�
,m�
),〈e,m〉 → 〈e�
,m�
〉
• ∃Ω ∈ {Ωd i v ,Ωar r ay ,Ωp t r },〈e,m〉 → Ω
� � �
Supposons que Γ � l v : t. Soit m un état mémoire tel que Γ � m.
Alors l’un des cas suivants est vrai :
• ∃ϕ,l v = ϕ
• ∃(l v�
,m�
),〈l v,m〉 → 〈l v�
,m�
〉
• ∃Ω ∈ {Ωd i v ,Ωar r ay ,Ωp t r },〈l v,m〉 → Ω
C’est-à-dire, soit :
• l’entité (instruction, expression ou valeur gauche) est complètement évaluée.
• un pas d’évaluation est possible.
• une erreur de division, tableau ou pointeur se produit.D.2. PROGRÈS 149
Démonstration. On procède par induction sur la dérivation du jugement de typage. Puisque
les jugements Γ � i, Γ � e : t et Γ � l v : t sont interdépendants, on traite tous les cas par
récursion mutuelle.
Le squelette de cette preuve est une analyse de cas selon la dernière règle utilisée. La plupart
des cas ont la même forme : on utilise l’hypothèse de récurrence sur les sous-éléments
syntaxiques (en appliquant éventuellement le lemme 5.1 d’inversion pour établir qu’ils sont
bien typés). Dans le cas « valeur », on appelle une règle qui permet de transformer une opé-
ration syntaxique en opération sémantique (par exemple, on transforme le + unaire en un +�
sémantique). Dans le cas « évaluation », on applique la règle CTX avec un contexte particulier
qui permet de passer d’un jugement 〈a,m〉 → 〈a�
,m�
〉 à un jugement 〈b,m〉 → 〈b�
,m�
〉 (où a
apparaît dans b). Enfin, dans le cas « erreur », on utilise EVAL-ERR avec ce même contexte C.
Ceci est valable pour la majorité des cas. Il faut faire attention en particulier aux opé-
rations sémantiques qui peuvent produire des erreurs (comme la division, ou l’opérateur
Lookup(·,·)).
Instructions
PASS : Ce cas est immédiat.
RETURN : Partant de i = RETURN(e), on applique le lemme d’inversion. Il nous donne l’existence
de t tel que Γ � e : t. On applique alors l’hypothèse de récurrence à e.
• e = v. Alors i = RETURN(v), ce qui nous permet de conclure.
• 〈e,m〉 → 〈e�
,m�
〉. Alors en appliquant CTX avec C = RETURN(•) 1, on conclut que
〈RETURN(e),m〉 → 〈RETURN(e�
),m�
〉.
• 〈e,m〉 → Ω. On applique EVAL-ERR avec ce même C.
SEQ : Avec i = i1;i2, on applique l’hypothèse de récurrence à i1.
• i1 = PASS. On peut donc appliquer la règle SEQ et donc 〈i,m〉 → 〈i2,m〉.
• i1 = RETURN(v). Alors on peut appliquer la règle RETURN : 〈i,m〉 → 〈RETURN(v),m〉.
• 〈i1,m〉 → 〈i�
1,m�
〉. Soit C = •;i2. Par CTX il vient 〈i,m〉 → 〈i�
1;i2,m�
〉.
• 〈i1,m〉 → Ω. Avec ce même C dans EVAL-ERR on trouve 〈i,m〉 → Ω.
EXP : Ici i = e. On peut appliquer l’hypothèse de récurrence à e qui est « plus petit » que i
(i ::= e introduit un constructeur implicite).
• e = v. Alors on peut appliquer EXP : 〈e,m〉 → 〈PASS,m〉.
• 〈e,m〉 → 〈e�
,m�
〉. Alors 〈i,m〉 → 〈e�
,m�
〉 (cela revient à appliquer CTX au constructeur
implicite mentionné ci-dessus).
• 〈e,m〉 → Ω. C’est-à-dire 〈i,m〉 → Ω.
1. Les contextes sont des objets purement syntaxiques : on peut les appliquer entre instructions et expressions
indifféremment150 ANNEXE D. PREUVES
DECL : Ici i = DECL x = e IN{i�
}. On commence par appliquer l’hypothèse de récurrence à e.
• e = v. On applique alors l’hypothèse de récurrence à i� sous Γ� = Γ,local x : t et avec
m� = Extend(m,x �→ v).
• i� = PASS. Dans ce cas la règle DECL-PASS s’applique.
• i� = RETURN(v). Idem avec DECL-RETURN.
• 〈i�
,m�
〉 → 〈i��,m��〉. On peut alors appliquer la règle DECL-CTX.
• 〈i�
,m�
〉 → Ω. On applique DECL-ERR.
• 〈e,m〉 → 〈e�
,m�
〉. On pose C = DECL x = • IN{i�
} et on conclut avec la règle CTX.
• 〈e,m〉 → Ω. Idem avec EVAL-ERR.
IF : Ici i = IF(e){i1}ELSE{i2}. On applique l’hypothèse de récurrence à e.
• e = v.
Si v �= 0, on applique IF-TRUE. Dans le cas contraire, on applique IF-FALSE.
• 〈e,m〉 → 〈e�
,m�
〉. On pose C = IF(•){i1}ELSE{i2} et on conclut avec CTX.
• 〈e,m〉 → Ω. Avec ce même C et EVAL-ERR.
WHILE : Ce cas est direct : on applique la règle d’évaluation WHILE.
Expressions
CST-INT : e est alors de la forme n, qui est une valeur.
CST-FLOAT : e est alors de la forme d, qui est une valeur.
CST-NULL : e est alors égale à NULL, qui est une valeur.
CST-UNIT : e est alors égale à ( ), qui est une valeur.
FUN : Ce cas est direct : la règle EXP-FUN s’applique.
OP-INT : Cela implique que e = e1 � e2. Par le lemme 5.1, on en déduit que Γ � e1 : INT et
Γ � e2 : INT.
Appliquons l’hypothèse de récurrence sur e1. Trois cas peuvent se produire.
• e1 = v1. On a alors 〈e1,m〉 = 〈v1,m�
〉 avec m� = m.
On applique l’hypothèse de récurrence à e2.
• e2 = v2 : alors 〈e2,m�
〉 = 〈v2,m��〉 avec m�� = m. On peut alors appliquer EXPBINOP,
sauf dans le cas d’une division par zéro (� ∈ {/;%;/.} et v2 = 0) où alors
v1 �� v2 = Ωd i v . Dans ce cas, on a alors par EXP-ERR 〈e,m〉 → Ωd i v . Notons que
comme les opérandes sont bien typés, Ωt yp ne peut pas être levée.
• ∃(e�
2,m��),〈e2,m�
〉 → 〈e�
2,m��〉.
En appliquant CTX avec C = v1 � •, on en déduit 〈v1 � e2,m�
〉 → 〈v1 � e�
2,m��〉
soit 〈e,m〉 → 〈v1 � e�
2,m��〉.
• 〈e2,m�
〉 → Ω. De EVAL-ERR avec C = v1 � • vient alors 〈e,m〉 → Ω.D.2. PROGRÈS 151
• ∃(e�
1,m�
),〈e1,m〉 → 〈e�
1,m�
〉. En appliquant CTX avec C = • � e2, on obtient
〈e1 � e2,m〉 → 〈e�
1 � e2,m�
〉, ou 〈e,m〉 → 〈e�
1 � e2,m�
〉.
• 〈e1,m〉 → Ω. D’après EVAL-ERR avec C = • � e2, on a 〈e,m〉 → Ω.
OP-FLOAT : Ce cas est similaire à OP-INT.
OP-EQ : Ce cas est similaire à OP-INT.
UNOP-PLUS-INT : Alors e = + e1. En appliquant l’hypothèse d’induction sur e1 :
• soit e1 = v1. Alors en appliquant EXP-UNOP, 〈+ v1,m〉 → 〈+� v1,m〉, c’est-à-dire en posant
v = +� v1, 〈e,m〉 → 〈v,m〉.
• soit ∃e�
1,m�
,〈e1,m〉 → 〈e�
1,m�
〉. Alors en appliquant CTX avec C = + •, on obtient
〈e,m〉 → 〈e�
1,m�
〉.
• soit 〈e1,m〉 → Ω. De EVAL-ERR avec C = + • il vient〈e,m〉 → Ω.
UNOP-PLUS-FLOAT : Ce cas est similaire à UNOP-PLUS-INT.
UNOP-MINUS-INT : Ce cas est similaire à UNOP-PLUS-INT.
UNOP-MINUS-FLOAT : Ce cas est similaire à UNOP-PLUS-INT.
UNOP-NOT : Ce cas est similaire à UNOP-PLUS-INT.
ADDR : On applique l’hypothèse de récurrence à l v.
Les cas d’évaluation et d’erreur sont traités en appliquant respectivement CTX et EVALERR
avec C = &•. Dans le cas où l v = ϕ, on peut appliquer EXP-ADDR.
SET : On applique l’hypothèse de récurrence à l v.
• l v = ϕ. On applique l’hypothèse de récurrence à e.
• e = v. Alors on peut appliquer EXP-SET.
• 〈e,m〉 → 〈e�
,m�
〉. On conclut avec C = ϕ ← •.
• 〈e,m〉 → Ω. Idem.
• 〈l v,m〉 → 〈l v�
,m�
〉. On conclut avec C = • ← e.
• 〈l v,m〉 → Ω. Idem.
ARRAY : On va appliquer l’hypothèse de récurrence à e1, puis, si e1 = v1, on l’applique à e2,
etc. Alors on se retrouve dans un des cas suivants :
• ∃p ∈ [1;n],e�
p,m : e1 = v1,...,ep−1 = vp−1,〈ep,m〉 → 〈e�
p,m�
〉. Alors on peut appliquer
CTX avec C = [v1;...; vp−1;•;ep+1;...;en].
• ∃p ∈ [1;n],Ω : e1 = v1,...,ep−1 = vp−1,〈ep,m〉 → Ω. Dans ce cas EVAL-ERR est applicable
avec ce même C.
• e1 = v1,...,en = vn. Alors on peut appliquer EXP-ARRAY en construisant un tableau.152 ANNEXE D. PREUVES
STRUCT : Le schéma de preuve est similaire au cas ARRAY. En cas de pas d’évaluation ou
d’erreur, on utilise le contexte C = {l1 : v1;...;lp−1 : vp−1;lp : •;lp+1 : ep+1;...;ln : en} ; et dans
le cas où toutes les expressions sont évaluées, on applique EXP-STRUCT.
CALL : On commence par appliquer l’hypothèse de récurrence à e. Dans le cas d’un pas
d’évaluation ou d’erreur, on applique respectivement CTX ou EVAL-ERR avecC = •(e1,...,en).
Reste le cas où e est une valeur : d’après le lemme 5.2, e est de la forme f = fun(a1,...,an){i}.
Ensuite, appliquons le même schéma que pour ARRAY. En cas de pas d’évaluation ou d’erreur,
on utilise CTX ou EVAL-ERR avec C = f (v1,..., vp−1,•,ep+1,...,en). Le seul cas restant est
celui où l’expression considérée a pour forme f (v1,..., vn) avec f = fun(a1,...,an){i}.
Soit Γ� = (ΓG , [a1 : t1,...,an : tn,R : t]) et m1 = Push(m0, (a1 �→ v1,...an �→ vn)) où Γ =
(ΓG ,ΓL).
On applique alors l’hypothèse de récurrence à Γ�
, m1 et i (le lemme d’inversion garantit
que Γ� � i).
• i = RETURN(v). Alors on applique EXP-CALL-RETURN.
• i = PASS. Ce cas est impossible puisqu’on prend l’hypothèse que les fonctions se terminent
par une instruction RETURN(·) (page 56).
• 〈i,m1〉 → 〈i�
,m2〉. Alors on peut appliquer EXP-CALL-CTX.
• 〈i,m〉 → Ω. On peut alors appliquer EXP-CALL-ERR.
Valeurs gauches
LV-VAR : Le but est d’appliquer PHI-VAR. La seule condition pour que cela soit possible est
que Lookup(x,m) renvoie une adresse et non Ωvar .
Puisque Γ � x : t, on peut appliquer le lemme 5.5 : x est soit une variable locale, soit une
globale. Dans ces deux cas, Lookup(x,m) renvoie une adresse correcte.
LV-DEREF : Appliquons l’hypothèse de récurrence à e vue en tant qu’expression.
• e = v. Puisque Γ � v : t∗, on déduit du lemme 5.2 que v = NULL ou v = &� ϕ.
Dans le premier cas, puisque 〈∗NULL,m〉 → Ωp t r , on a 〈e,m〉 → Ωp t r .
Dans le second cas, EXP-DEREF s’applique.
• 〈e,m〉 → 〈e�
,m�
〉. De CTX avec C = ∗•, on obtient 〈e,m〉 → 〈∗e�
,m�
〉.
• 〈e,m〉 → Ω. En appliquant EVAL-ERR avec C = ∗•, on obtient 〈e,m〉 → Ω.
LV-INDEX : De même, on applique l’hypothèse de récurrence à l v.
• l v = v.
Comme Γ � v : t[ ], on déduit du lemme 5.2 que v = [v1;...; vp]. Appliquons l’hypothèse
de récurrence à e.
• e = v�
. Puisque Γ � e : INT, on réapplique le lemme 5.2 et v� = n. D’après PHIARRAY,〈l
v[e],m〉 → 〈[v1;...; vp][
�n],m〉. Deux cas sont à distinguer : si n ∈ [0;p−1],
la partie droite vaut vn+1 et donc 〈l v[e],m〉 → 〈vn+1,m〉. Sinon elle vaut Ωar r ay et
〈l v[e],m〉 → Ωar r ay par EXP-ERR.
• 〈e,m〉 → 〈e�
,m�
〉. En appliquant CTX avec C = v[•], on en déduit 〈l v[e],m〉 →
〈l v[e�
],m�
〉.
• 〈e,m〉 → Ω. Avec EVAL-ERR sous ce même contexte, 〈l v[e],m〉 → ΩD.3. PRÉSERVATION 153
• 〈l v,m〉 → 〈e�
,m�
〉. On applique alors CTX avec C = •[e], et 〈l v[e],m〉 → 〈e�
[e],m�
〉.
• 〈l v,m〉 → Ω. Toujours avec C = •[e], de EVAL-ERR il vient 〈l v[e],m〉 → Ω.
LV-FIELD : On applique l’hypothèse de récurrence à l v.
• l v = ϕ Alors PHI-STRUCT s’applique. Puisque (l,t) ∈ S, l’accès au champ l ne provoque
pas d’erreur Ωf i eld . Donc 〈e,m〉 → 〈ϕ[l],m〉.
• 〈l v,m〉 → 〈l v�
,m�
〉 En appliquant CTX avec C = •.lS, il vient 〈l v,m〉 → 〈l v�
,m�
〉.
• 〈l v,m〉 → Ω En appliquant EVAL-ERR avec C = •.lS, on a 〈l v,m〉 → Ω.
PTR-ARITH : Le schéma est similaire au cas OP-INT. Le seul cas intéressant arrive lorsque
e1 et e2 sont des valeurs. D’après le lemme 5.2 :
• e1 = NULL ou e1 = ϕ
• e2 = n
D’après EXP-BINOP, 〈e,m〉 → 〈e1 �� n,m〉.
On se réfère ensuite à la définition de �� (page 55) : si e1 est de la forme ϕ[m], alors e1 �� n =
ϕ[m +n]. Donc 〈e,m〉 → 〈ϕ[m +n],m〉.
Dans les autres cas (e1 = NULL ou e1 = ϕ avec ϕ pas de la forme ϕ�
[m]), on a e1 �� n = Ωp t r .
Donc d’après EXP-ERR, 〈e,m〉 → Ωp t r .
D.3 Préservation
On rappelle l’énoncé du théorème 5.2.
Théorème D.2 (Préservation). Soient Γ un environnement de typage, et m un état mémoire
tels que Γ � m.
Alors :
• Si Γ � l v : t et 〈l v,m〉 → 〈ϕ,m�
〉, alors Γ � Cleanup(m�
) et m� �Φ ϕ : τ où τ�t.
• Si Γ � l v : t et 〈l v,m〉 → 〈l v�
,m�
〉, alors Γ � Cleanup(m�
) et Γ � l v� : t.
• Si Γ � e : t et 〈e,m〉 → 〈v,m�
〉, alors Γ � Cleanup(m�
) et m� � v : τ où τ�t.
• Si Γ � e : t et 〈e,m〉 → 〈e�
,m�
〉, alors Γ � Cleanup(m�
) et Γ � e� : t.
• Si Γ � i et 〈i,m〉 → 〈i�
,m�
〉, alors Γ � Cleanup(m�
) et Γ � i�
.
Autrement dit, si une construction est typable, alors un pas d’évaluation ne modifie pas son
type et préserve le typage de la mémoire.
Démonstration. On procède par induction sur la dérivation de 〈·,m〉 → 〈·,m�
〉. Plusieurs remarques
sont à faire : d’abord, en ce qui concerne le typage de la mémoire, il suffit de montrer
que Γ � m� car cela implique que Γ � Cleanup(m�
). Ensuite, la règle CTX est traitée à part, car
elle peut être appliquée en contexte d’expression, d’instruction ou de valeur gauche. Enfin
la règle TRANS ne pose pas de problème, il suffit d’appliquer l’hypothèse de récurrence à ses
prémisses.
Cas Γ � l v : t et 〈l v,m〉 → 〈ϕ,m�
〉
EXP-DEREF : On sait que Γ � ∗ v : t où v = &� ϕ. Par inversion, Γ � v : t ∗. Alors d’après
le lemme 5.3, il existe τ� tel que m � v : τ� et τ� � t ∗. Par inversion de la relation de typage
sémantique, τ� = τ ∗ où τ�t. Alors par inversion de S-PTR, on obtient que m �Φ ϕ : τ.154 ANNEXE D. PREUVES
PHI-VAR, PHI-STRUCT et PHI-ARRAY : Il n’est pas nécessaire de montrer la compatibilité
de m� car la mémoire n’est pas modifiée. De plus, les prémisses de ces règles ont la forme ϕ ,
donc le lemme 5.6 s’applique avec la conclusion correcte.
Cas Γ � e : t et 〈e,m〉 → 〈v,m�
〉
EXP-CST : Toutes les constantes sont des valeurs, donc le lemme 5.3 peut s’appliquer : τ =
Repr(t) convient.
EXP-FUN : Idem : le lemme de représentabilité nous donne un candidat τ = Repr(t) qui
convient.
EXP-LV : Puisque Γ � ϕ : t et Γ � m, on a d’après le lemme 5.6 : m � v : τ où v = m[ϕ] avec
τ�t.
EXP-UNOP : Il vient des définitions des différents opérateurs �� que Γ � �� v : τ avec τ�t.
EXP-BINOP : Idem avec les définitions des opérateurs �� .
EXP-ADDR : On peut appliquer le lemme 5.3, qui nous donne un τ qui convient.
EXP-SET : Deux propriétés sont à prouver. D’une part, Γ � v : t, et d’autre part, Γ � m� où
m� = m[ϕ ← v]. Tout d’abord, le lemme d’inversion appliqué à Γ � ϕ ← v : t nous donne que
Γ � ϕ : t et Γ � v : t. Ensuite, comme Γ � ϕ : t et Γ � m, on peut appliquer le lemme 5.3 : il
existe τ tel que m � v : τ et τ� t. On peut donc appliquer le lemme 5.6, qui nous permet de
conclure que Γ � m�
.
EXP-STRUCT : Le lemme 5.3 s’applique à ce cas.
EXP-ARRAY : Idem, on conclut grâce au lemme de représentabilité.
EXP-CALL-RETURN : Par inversion, il vient que Γ � fun(a1,...,an){i} : (t1,...,tn) → t� et ∀i ∈
[1;n],Γ � vi : ti .
Posons Γ� = (ΓG , [a1 : t1,...,an : tn,R : t�
]) où Γ = (ΓG ,ΓL). Alors par inversions successives
on obtient que Γ� � RETURN(v) et Γ� � v : t�
.
Si on définit m�� = Push(m,a1 �→ v1,...,an �→ vn), alors par M-PUSH on obtient que Γ� �
m��. Donc, par le lemme 5.3, il existe τ tel que m�� � v : τ où τ�t�
.
Il reste à montrer que m� � v� : τ.
On distingue selon la forme de v. On applique un raisonnement similaire à celui de la
preuve du lemme 5.6 : soit v est une référence au cadre nettoyé, et dans ce cas v� = NULL et τ
est un type pointeur, soit v� = v. Dans tous les cas on conclut car m� � v� : τ.
Cas Γ � i et 〈i,m〉 → 〈i�
,m�
〉
SEQ : D’après le lemme d’inversion, Γ � i.
EXP : D’après PASS, Γ � PASS.D.3. PRÉSERVATION 155
DECL-PASS : Γ � PASS est immédiat, et Γ � m� est établi par M-DECL suivie de
M-DECLCLEAN. On a bien x ∉ Γ car les déclarations de variable ne peuvent pas masquer
de variables visibles existantes (page 57).
DECL-RETURN : La compatibilité mémoire se démontre de la même manière que pour
DECL-PASS. Il reste à montrer que Γ � RETURN(v��), ce qui fait de manière analogue au cas
EXP-CALL-RETURN.
DECL-CTX : On part de Γ � DECL x = v IN{i}. Par inversion, il existe t tel que Γ � v : t et
Γ� � i où Γ� = Γ,local x : t.
Comme Γ � m, le lemme 5.3 s’applique : il existe τ tel que m � v : τ où τ�t. De plus x ∉ Γ
car il n’y a pas de masquage (page 57).
En appliquant M-DECL, on obtient donc que Γ� � m�
.
On applique alors l’hypothèse d’induction à 〈i,m�
〉 → 〈i�
,m��〉. Il vient que Γ� � i� et Γ� �
m��.
On a donc Γ� � DECL x = v� IN{i�
} par DECL et à � Cleanup(m���) par M-DECLCLEAN.
IF-FALSE : D’après le lemme d’inversion, Γ � if .
IF-TRUE : D’après le lemme d’inversion, Γ � it .
WHILE : D’après le lemme d’inversion, Γ � e : t et Γ � i. Par SEQ, on a Γ � i;WHILE(e){i}.
Enfin par IF il vient Γ � IF(e){i;WHILE(e){i}}ELSE{PASS}.
RETURN : Par le lemme d’inversion, Γ � RETURN(v).
EXP-CALL-CTX : On sait que Γ � fun(a1,...,an){i}(v1,..., vn) et Γ � m0. D’après le lemme
d’inversion, il existe t1,...,tn tels que ∀i ∈ [1;n],Γ � vi : ti , Γ � fun(a1,...,an){i} : (t1,...,tn) →
t, donc qu’en posant Γ� = (ΓG , [a1 : t1,...,an : tn,R : t]) où Γ = (ΓG ,ΓL) on a Γ� � i.
D’un autre côté, il existe par le lemme 5.3 des types τi tels que ∀i ∈ [1;n],m0 � vi : τi avec
τi �ti . En appliquant M-PUSH, on a donc Γ� � m1.
On peut alors appliquer l’hypothèse d’induction à 〈i,m1〉 → 〈i�
,m2〉 : la conclusion est
que Γ� � i� et Γ� � m2. Comme Γ� � ai : ti , on a ∀i ∈ [1;n],Γ� � v�
i : ti . Donc on a bien Γ �
fun(a1,...,an){i�
}(v�
1,..., v�
n) : t.
D’autre part, en appliquant M-POP, on obtient que Γ � Cleanup(m3).
À propos de la règle CTX
L’application de la règle CTX nécessite une explication particulière. En effet, ce cas repose
sur un lemme d’inversion des constructions typées sous un contexte, qui est admis ici.
Par exemple, traitons le cas où le contexte C est tel que son « trou » soit une valeur gauche
l v et C�l v� est une instruction (les autres cas sont similaires). La règle appliquée est alors de
la forme :
〈l v,m〉 → 〈l v�
,m�
〉
〈C�l v�,m〉 → 〈C�l v�
�,m�
〉
(CTX)
Si Γ � C�l v�, on admet qu’il existe Γ� et t tels que :
• Γ� � l v : t ;156 ANNEXE D. PREUVES
• Quel que soit l v�
, si Γ� � l v� : t, alors Γ � C�l v�
�.
Par exemple, pour C = •[2] = 1, Γ� = Γ et t = INT[ ] conviennent. Pour C = DECL x =
0 IN{• = 3.0}, on prendra Γ� = Γ,local x : INT et t = FLOAT. Le fait de passer « sous » une dé-
claration ajoute une variable locale à Γ, et ainsi l’ensemble des variables de Γ� contient celui
de Γ.
Pour prouver la préservation dans ce cas, on commence par appliquer l’hypothèse de
récurrence à la prémisse de CTX, c’est-à-dire 〈l v,m〉 → 〈l v�
,m�
〉. Il vient que Γ� � l v� : t et
Γ� � Cleanup(m�
).
D’après le précédent lemme d’inversion on en déduit que Γ � C�l v�
�. De plus Γ� contient
plus de variables que Γ donc Γ � Cleanup(m�
).
D.4 Progrès pour les extensions noyau
(Théorème 6.1)
Démonstration. On procède de la même manière que pour le théorème 5.1 (prouvé en annexe
D.2). En fait, puisque le schéma de preuve porte sur les règles de typage, il suffit de traiter
les cas supplémentaires.
ADDR-USER : Alors e = ♦ l v. On applique l’hypothèse de récurrence à l v.
• l v = ϕ. Alors on peut appliquer PHI-USER.
• 〈l v,m〉 → 〈l v�
,m�
〉. On conclut en utilisant CTX avec C = ♦ •.
• 〈l v,m〉 → Ω. On applique EVAL-ERR avec ce même C.
USER-GET : On applique l’hypothèse de récurrence à ed .
• ed = vd . On applique l’hypothèse de récurrence à es.
• es = vs.
D’après le lemme 5.2 adapté aux extensions noyau, vs a pour forme ϕs.
On distingue la forme de ϕs :
• ϕs = ♦� ϕ. Alors on applique USER-GET-OK. Le lemme 5.6 adapté aux extensions
noyau assure que les prémisses sont correctes.
• � ϕ,ϕs = ♦� ϕ. Alors on applique USER-GET-ERR.
• 〈es,m〉 → 〈e�
s,m�
〉. Posons C = copy_from_user(vd ,•). On conclut avec CTX.
• 〈es,m〉 → Ω. Idem avec EVAL-ERR.
• 〈ed ,m〉 → 〈e�
d ,m�
〉. On applique CTX avec C = copy_from_user(•,es).
• 〈ed ,m〉 → Ω. On utilise EVAL-ERR avec ce même contexte.
USER-PUT : Ce cas est similaire au cas USER-GET, en appliquant les règles USER-PUT-OK
et USER-PUT-ERR.D.5. PRÉSERVATION POUR LES EXTENSIONS NOYAU 157
D.5 Préservation pour les extensions noyau
(Théorème 6.2)
De même, il suffit de prouver les cas correspondant aux nouvelles règles.
PHI-USER : On applique le lemme de représentation, qu’on étend avec le cas
Repr(t @) = Repr(t) @.
USER-GET-OK : Tout d’abord, d’après le lemme 6.1, t = INT, donc la préservation du type
est établie car m� � 0 : INT. La compatibilité mémoire est obtenue en appliquant M-WRITE.
USER-GET-ERR : La seule partie à prouver est la préservation, qui se fait de la même manière
que dans le cas précédent.
USER-PUT-OK : Idem que dans le cas USER-PUT-OK.
USER-PUT-ERR : Idem que pour USER-GET-ERR.LISTE DES FIGURES
1.1 Surapproximation. L’ensemble des états erronés est hachuré. L’ensemble des états
effectifs du programme, noté par des points, est approximé par l’ensemble en gris. 9
1.2 Utilisation de l’attribut non-standard packed . . . . . . . . . . . . . . . . . . . . . . 10
2.1 Mécanisme de mémoire virtuelle. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
2.2 Implantation de la mémoire virtuelle . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
2.3 Appel de gettimeofday . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
2.4 Implantation de l’appel système gettimeofday . . . . . . . . . . . . . . . . . . . . . 19
3.1 Domaine des signes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
3.2 Quelques domaines abstraits . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
3.3 Treillis de qualificateurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
4.1 Fonctionnement d’une lentille . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
4.2 Fonctionnement d’une lentille indexée . . . . . . . . . . . . . . . . . . . . . . . . . . 38
4.3 Composition de lentilles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
4.4 Syntaxe des expressions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
4.5 Syntaxe des instructions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
4.6 Syntaxe des opérateurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
4.7 Valeurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44
4.8 Composantes d’un état mémoire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44
4.9 Opérations de pile . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
4.10 Cassage du typage par un pointeur fou . . . . . . . . . . . . . . . . . . . . . . . . . . 48
4.11 Dépendances entre les lentilles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50
4.12 Contextes d’évaluation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53
4.13 Évaluation stricte ou paresseuse des valeurs gauches . . . . . . . . . . . . . . . . . 54
5.1 Programmes bien et mal formés . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
5.2 Types et environnements de typage . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
5.3 Jugements d’égalité sur les types . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
5.4 Typage des phrases et programmes . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70
5.5 Types de valeurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71
5.6 Règles de typage des valeurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71
5.7 Compatibilité entre types de valeurs et statiques . . . . . . . . . . . . . . . . . . . . 72
5.8 Compatibilité entre états mémoire et environnements de typage . . . . . . . . . . 72
6.1 Interface permettant d’ouvrir un fichier sous Unix . . . . . . . . . . . . . . . . . . . 82
6.2 Ajouts liés aux entiers utilisés comme bitmasks . . . . . . . . . . . . . . . . . . . . . 83
6.3 Nouvelles valeurs liées aux bitmasks . . . . . . . . . . . . . . . . . . . . . . . . . . . 84
6.4 Dérivation montrant que ! (x & y) est bien typée . . . . . . . . . . . . . . . . . . . . 85
6.5 Implantation d’un appel système qui remplit une structure par pointeur . . . . . . 87
6.6 Ajouts liés aux pointeurs utilisateur (par rapport à l’interprète du chapitre 4) . . . 87
6.7 Appel de la fonction sys_getver de la figure 6.5 . . . . . . . . . . . . . . . . . . . . . 88
6.8 Ajouts liés aux pointeurs utilisateur (par rapport aux figures 5.2 et 5.5) . . . . . . . 90
159160 Liste des figures
7.1 Syntaxe simplifiée de NEWSPEAK . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 100
7.2 Compilation du flot de contrôle en NEWSPEAK . . . . . . . . . . . . . . . . . . . . . 101
7.3 Fonction principale de ptrtype . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 103
7.4 Algorithme d’unification . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 104
7.5 Unification directe ou retardée . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 105
7.6 Inférence des déclarations de variable et appels de fonction . . . . . . . . . . . . . 106
8.1 Espace d’adressage d’un processus . . . . . . . . . . . . . . . . . . . . . . . . . . . . 112
8.2 Fonction de définition d’un appel système . . . . . . . . . . . . . . . . . . . . . . . . 113
8.3 Code de la fonction radeon_info_ioctl . . . . . . . . . . . . . . . . . . . . . . . . . 114
8.4 Définition de struct drm_radeon_info . . . . . . . . . . . . . . . . . . . . . . . . . 114
8.5 Patch résolvant le problème de pointeur utilisateur. . . . . . . . . . . . . . . . . . . 115
8.6 Implantation de ptrace sur architecture Blackfin . . . . . . . . . . . . . . . . . . . 116
8.7 Cas d’étude « Radeon » minimisé et annoté . . . . . . . . . . . . . . . . . . . . . . . 118
8.8 Cas d’étude « Radeon » minimisé et annoté – version correcte . . . . . . . . . . . . 118
8.9 Cas d’étude « Blackfin » . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 119
8.10 Traduction en NEWSPEAK du cas d’étude « Blackfin » . . . . . . . . . . . . . . . . . . 121LISTE DES DÉFINITIONS
4.1 Définition (Lentille) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
4.2 Définition (Lentille indexée) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
4.3 Définition (Composition de lentilles) . . . . . . . . . . . . . . . . . . . . . . . . . 39
4.4 Définition (Recherche de variable) . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
4.5 Définition (Manipulations de pile) . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
4.6 Définition (Hauteur d’une valeur) . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
LISTE DES THÉORÈMES ET LEMMES
5.1 Lemme (Inversion) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
5.2 Lemme (Formes canoniques) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 74
5.3 Lemme (Représentabilité) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 74
5.4 Lemme (Hauteur des chemins typés) . . . . . . . . . . . . . . . . . . . . . . . . . 75
5.5 Lemme (Accès à des variables bien typées) . . . . . . . . . . . . . . . . . . . . . . 75
5.6 Lemme (Accès à une mémoire bien typée) . . . . . . . . . . . . . . . . . . . . . . 76
5.1 Théorème (Progrès) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77
5.2 Théorème (Préservation) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78
5.3 Théorème (Progrès pour les phrases) . . . . . . . . . . . . . . . . . . . . . . . . . 78
5.4 Théorème (Préservation pour les phrases) . . . . . . . . . . . . . . . . . . . . . . 78
6.1 Lemme (Inversion du typage) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92
6.1 Théorème (Progrès pour les extensions noyau) . . . . . . . . . . . . . . . . . . . 92
6.2 Théorème (Préservation pour les extensions noyau) . . . . . . . . . . . . . . . . 92
D.1 Théorème (Progrès) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148
D.2 Théorème (Préservation) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 153
161RÉFÉRENCES WEB
[☞1] The C - - language
http://www.cminusminus.org/
[☞2] OCaml – Home
http://ocaml.org/
[☞3] Penjili project
https://bitbucket.org/iwseclabs/c2newspeak
[☞4] Python Programming Language – Official Website
http://www.python.org/
[☞5] The Rust Programming Language
http://www.rust-lang.org/
[☞6] Sparse - a Semantic Parser for C
https://sparse.wiki.kernel.org/index.php/Main_Page
163BIBLIOGRAPHIE
[AB07] Andrew W. Appel and Sandrine Blazy. Separation logic for small-step Cminor. In
Proceedings of the 20th International Conference on Theorem Proving in Higher
Order Logics, TPHOLs 2007, pages 5–21, 2007.
[ABD+07] Alex Aiken, Suhabe Bugrara, Isil Dillig, Thomas Dillig, Brian Hackett, and Peter
Hawkins. An overview of the saturn project. In Proceedings of the 7th ACM
SIGPLAN-SIGSOFT workshop on Program analysis for software tools and engineering,
PASTE ’07, pages 43–48. ACM, 2007.
[AH07] Xavier Allamigeon and Charles Hymans. Analyse statique par interprétation abstraite.
In Eric Filiol, editor, 5ème Symposium sur la Sécurité des Technologies de
l’Information et des Communications (SSTIC’07), 2007.
[BA08] S. Bugrara and A. Aiken. Verifying the Safety of User Pointer Dereferences. In
Security and Privacy, 2008. SP 2008. IEEE Symposium on, pages 325–338, 2008.
[BBC+10] Al Bessey, Ken Block, Ben Chelf, Andy Chou, Bryan Fulton, Seth Hallem, Charles
Henri-Gros, Asya Kamsky, Scott McPeak, and Dawson Engler. A few billion lines
of code later : using static analysis to find bugs in the real world. Commun. ACM,
53(2) :66–75, 2010.
[BC05] Daniel P. Bovet and Marco Cesati. Understanding the Linux Kernel, Third Edition.
O’Reilly Media, third edition edition, 2005.
[BCC+03] Armin Biere, Alessandro Cimatti, Edmund M. Clarke, Ofer Strichman, and Yunshan
Zhu. Bounded model checking. Advances in Computers, 58 :117–148, 2003.
[BDH+09] Julien Brunel, Damien Doligez, René Rydhof Hansen, Julia L. Lawall, and Gilles
Muller. A foundation for flow-based program matching using temporal logic and
model checking. In The 36th Annual ACM SIGPLAN - SIGACT Symposium on
Principles of Programming Languages, POPL, pages 114–126, 2009.
[BDL06] Sandrine Blazy, Zaynah Dargaye, and Xavier Leroy. Formal verification of a C
compiler front-end. In FM 2006 : Int. Symp. on Formal Methods, volume 4085 of
Lecture Notes in Computer Science, pages 460–475. Springer, 2006.
[BDN09] Ana Bove, Peter Dybjer, and Ulf Norell. A brief overview of Agda — a functional
language with dependent types. In Proceedings of the 22nd International
Conference on Theorem Proving in Higher Order Logics, TPHOLs ’09, pages 73–
78. Springer-Verlag, 2009.
[BGH10] Lennart Beringer, Robert Grabowski, and Martin Hofmann. Verifying pointer and
string analyses with region type systems. In Proceedings of the 16th International
Conference on Logic for Programming, Artificial Intelligence, and Reasoning,
LPAR’10, pages 82–102. Springer-Verlag, 2010.
[BLS05] Mike Barnett, K. Rustan M. Leino, and Wolfram Schulte. The Spec# programming
system : an overview. In Proceedings of the 2004 international conference
on Construction and Analysis of Safe, Secure, and Interoperable Smart Devices,
CASSIS’04, pages 49–69. Springer-Verlag, 2005.
165166 BIBLIOGRAPHIE
[CC77] Patrick Cousot and Radhia Cousot. Abstract interpretation : a unified lattice model
for static analysis of programs by construction or approximation of fixpoints.
In Proceedings of the 4th ACM SIGACT-SIGPLAN symposium on Principles of Programming
Languages, POPL ’77, pages 238–252. ACM, 1977.
[CC92] P. Cousot and R. Cousot. Abstract interpretation and application to logic programs.
Journal of Logic Programming, 13(2–3) :103–179, 1992. (The editor of Journal
of Logic Programming has mistakenly published the unreadable galley proof. For a correct version
of this paper, see http://www.di.ens.fr/~cousot.).
[CCF+05] Patrick Cousot, Radhia Cousot, Jérôme Feret, Laurent Mauborgne, Antoine Miné,
David Monniaux, and Xavier Rival. The ASTREÉ analyzer. In Proceedings of the
14th European Symposium on Programming, ESOP 2005, pages 21–30, 2005.
[CCF+09] Patrick Cousot, Radhia Cousot, Jérôme Feret, Laurent Mauborgne, Antoine Miné,
and Xavier Rival. Why does Astrée scale up ? Formal Methods in System Design,
35(3) :229–264, 2009.
[CE81] Edmund M. Clarke and E. Allen Emerson. Design and synthesis of synchronization
skeletons using branching-time temporal logic. In Dexter Kozen, editor,
Logic of Programs, volume 131 of Lecture Notes in Computer Science, pages 52–71.
Springer, 1981.
[CH78] P. Cousot and N. Halbwachs. Automatic discovery of linear restraints among
variables of a program. In Conference Record of the Fifth Annual ACM SIGPLANSIGACT
Symposium on Principles of Programming Languages, POPL ’78, pages
84–97. ACM Press, New York, NY, 1978.
[CMP03] Emmanuel Chailloux, Pascal Manoury, and Bruno Pagano. Développement d’applications
avec Objective CAML. O’Reilly, 2003.
[DDMP10] Javier De Dios, Manuel Montenegro, and Ricardo Peña. Certified absence of dangling
pointers in a language with explicit deallocation. In Proceedings of the 8th
international conference on Integrated formal methods, IFM’10, pages 305–319.
Springer-Verlag, 2010.
[DM82] Luis Damas and Robin Milner. Principal type-schemes for functional programs.
In Proceedings of the 9th ACM SIGPLAN-SIGACT symposium on Principles of programming
languages, POPL ’82, pages 207–212. ACM, 1982.
[DRS03] Nurit Dor, Michael Rodeh, and Mooly Sagiv. CSSV : Towards a realistic tool for
statically detecting all buffer overflows in C. In Proceedings of the ACM SIGPLAN
2003 conference on Programming language design and implementation, PLDI ’03,
pages 155–167. ACM, 2003.
[EH94] Ana Erosa and Laurie J. Hendren. Taming control flow : A structured approach to
eliminating goto statements. In In Proceedings of 1994 IEEE International Conference
on Computer Languages, pages 229–240. IEEE Computer Society Press,
1994.
[FFA99] Jeffrey S. Foster, Manuel Fähndrich, and Alexander Aiken. A theory of type quali-
fiers. In Proceedings of the 1999 ACM SIGPLAN conference on Programming language
design and implementation, PLDI ’99, pages 192–203, 1999.BIBLIOGRAPHIE 167
[FGM+07] J. Nathan Foster, Michael B. Greenwald, Jonathan T. Moore, Benjamin C. Pierce,
and Alan Schmitt. Combinators for bidirectional tree transformations : A linguistic
approach to the view-update problem. ACM Trans. Program. Lang. Syst.,
29(3), 2007.
[FJKA06] Jeffrey S. Foster, Robert Johnson, John Kodumal, and Alex Aiken. Flow-insensitive
type qualifiers. ACM Trans. Program. Lang. Syst., 28 :1035–1087, 2006.
[Flo67] Robert W. Floyd. Assigning Meanings to Programs. In J. T. Schwartz, editor, Proceedings
of a Symposium on Applied Mathematics, volume 19 of Mathematical
Aspects of Computer Science, pages 19–31. American Mathematical Society, 1967.
[FTA02] Jeffrey S. Foster, Tachio Terauchi, and Alex Aiken. Flow-sensitive type qualifiers.
In Proceedings of the 2002 ACM SIGPLAN Conference on Programming language
design and implementation, PLDI ’02, pages 1–12. ACM, 2002.
[GGTZ07] Stephane Gaubert, Eric Goubault, Ankur Taly, and Sarah Zennou. Static analysis
by policy iteration on relational domains. In Rocco Nicola, editor, Programming
Languages and Systems, volume 4421 of Lecture Notes in Computer Science, pages
237–252. Springer Berlin, 2007.
[GMJ+02] Dan Grossman, Greg Morrisett, Trevor Jim, Michael Hicks, Yanling Wang, and
James Cheney. Region-based memory management in Cyclone. SIGPLAN Not.,
37(5) :282–293, 2002.
[Gon07] Georges Gonthier. The four colour theorem : Engineering of a formal proof. In
8th Asian Symposium on Computer Mathematics, ASCM 2007, page 333, 2007.
[Gor04] Mel Gorman. Understanding the Linux Virtual Memory Manager. Prentice Hall
PTR, 2004.
[Gra92] Philippe Granger. Improving the results of static analyses programs by local
decreasing iteration. In Proceedings of the 12th Conference on Foundations of
Software Technology and Theoretical Computer Science, pages 68–79. SpringerVerlag,
1992.
[Har88] Norm Hardy. The confused deputy (or why capabilities might have been invented).
ACM Operating Systems Review, 22(4) :36–38, 1988.
[HL08] Charles Hymans and Olivier Levillain. Newspeak, Doubleplussimple Minilang
for Goodthinkful Static Analysis of C. Technical Note 2008-IW-SE-00010-1, EADS
IW/SE, 2008.
[Hoa69] C. A. R. Hoare. An axiomatic basis for computer programming. Commun. ACM,
12(10) :576–580, 1969.
[ISO99] ISO. The ANSI C standard (C99). Technical Report WG14 N1124, ISO/IEC, 1999.
[JMG+02] Trevor Jim, J. Greg Morrisett, Dan Grossman, Michael W. Hicks, James Cheney,
and Yanling Wang. Cyclone : A safe dialect of c. In Proceedings of the General
Track of the annual conference on USENIX Annual Technical Conference, ATEC
’02, pages 275–288. USENIX Association, 2002.
[Jon10] M. Tim Jones. User space memory access from the Linux kernel. http://www.
ibm.com/developerworks/library/l-kernel-memory-access/, 2010.
[JW04] Robert Johnson and David Wagner. Finding user/kernel pointer bugs with type
inference. In USENIX Security Symposium, pages 119–134, 2004.168 BIBLIOGRAPHIE
[KcS07] Oleg Kiselyov and Chung chieh Shan. Lightweight static capabilities. Electr. Notes
Theor. Comput. Sci., 174(7) :79–104, 2007.
[Ker81] Brian W. Kernighan. Why Pascal is not my favorite programming language. Technical
report, AT&T Bell Laboratories, 1981.
[KR88] Brian W. Kernighan and Dennis M. Ritchie. The C Programming Language Second
Edition. Prentice-Hall, Inc., 1988.
[LA04] Chris Lattner and Vikram Adve. LLVM : A Compilation Framework for Lifelong
Program Analysis & Transformation. In Proceedings of the 2004 International
Symposium on Code Generation and Optimization (CGO’04), 2004.
[Lan96] Gérard Le Lann. The ariane 5 flight 501 failure - a case study in system engineering
for computing systems, 1996.
[LBR06] Gary T. Leavens, Albert L. Baker, and Clyde Ruby. Preliminary design of jml : A
behavioral interface specification language for java. SIGSOFT Softw. Eng. Notes,
31(3) :1–38, 2006.
[LDG+10] Xavier Leroy, Damien Doligez, Jacques Garrigue, Didier Rémy, and Jérôme
Vouillon. The Objective Caml system, documentation and user’s manual – release
3.12. INRIA, 2010.
[LZ06] Peng Li and Steve Zdancewic. Encoding information flow in Haskell. In Proceedings
of the 19th IEEE Workshop on Computer Security Foundations (CSFW ’06).
IEEE Computer Society, 2006.
[Mai90] Harry G. Mairson. Deciding ML typability is complete for deterministic exponential
time. In Proceedings of the Seventeenth Annual ACM Symposium on Principles
of Programming Languages, POPL ’90, pages 382–401, 1990.
[Mau04] Laurent Mauborgne. ASTRÉE : Verification of absence of run-time error. In
René Jacquart, editor, Building the information Society (18th IFIP World Computer
Congress), pages 384–392. The International Federation for Information Processing,
Kluwer Academic Publishers, 2004.
[McA03] David A. McAllester. Joint RTA-TLCA invited talk : A logical algorithm for ML
type inference. In Proceedings of the 14th International Conference on Rewriting
Techniques and Applications, RTA, pages 436–451, 2003.
[Mer03] J. Merrill. GENERIC and GIMPLE : a new tree representation for entire functions.
In GCC developers summit 2003, pages 171–180, 2003.
[MG07] Magnus O. Myreen and Michael J.C. Gordon. A Hoare logic for realistically modelled
machine code. In Tools and Algorithms for the Construction and Analysis
of Systems (TACAS 2007), LNCS, pages 568–582. Springer-Verlag, 2007.
[Mil78] Robin Milner. A theory of type polymorphism in programming. Journal of Computer
and System Sciences, 17(3) :348–375, 1978.
[Min01a] A. Miné. A new numerical abstract domain based on difference-bound matrices.
In Proc. of the Second Symposium on Programs as Data Objects (PADO II), volume
2053 of Lecture Notes in Computer Science (LNCS), pages 155–172. Springer, 2001.
http://www.di.ens.fr/~mine/publi/article-mine-padoII.pdf.BIBLIOGRAPHIE 169
[Min01b] A. Miné. The octagon abstract domain. In Proc. of the Workshop on Analysis,
Slicing, and Transformation (AST’01), pages 310–319. IEEE CS Press, 2001. http:
//www.di.ens.fr/~mine/publi/article-mine-ast01.pdf.
[NCH+05] George C. Necula, Jeremy Condit, Matthew Harren, Scott McPeak, and Westley
Weimer. Ccured : type-safe retrofitting of legacy software. ACM Trans. Program.
Lang. Syst., 27(3) :477–526, 2005.
[New00] Tim Newsham. Format string attacks. Phrack, 2000.
[NMRW02] George C. Necula, Scott McPeak, Shree Prakash Rahul, and Westley Weimer. CIL :
Intermediate language and tools for analysis and transformation of C programs.
In Proceedings of the 11th International Conference on Compiler Construction,
CC ’02, pages 213–228. Springer-Verlag, 2002.
[NPW02] Tobias Nipkow, Lawrence C. Paulson, and Markus Wenzel. Isabelle/HOL — A
Proof Assistant for Higher-Order Logic, volume 2283 of LNCS. Springer, 2002.
[oEE08] Institute of Electrical and Electronics Engineers. IEEE Standard for FloatingPoint
Arithmetic. Technical report, Microprocessor Standards Committee of the
IEEE Computer Society, 2008.
[OGS08] Bryan O’Sullivan, John Goerzen, and Don Stewart. Real World Haskell. O’Reilly
Media, Inc., 1st edition, 2008.
[Oiw09] Yutaka Oiwa. Implementation of the memory-safe full ANSI-C compiler. In Proceedings
of the 2009 ACM SIGPLAN conference on Programming language design
and implementation, PLDI ’09, pages 259–269. ACM, 2009.
[Pel93] Doron Peled. All from one, one for all : On model checking using representatives.
In Proceedings of the 5th International Conference on Computer Aided Verification,
CAV ’93, pages 409–423. Springer-Verlag, 1993.
[Pie02] Benjamin C. Pierce. Types and Programming Languages. MIT Press, 2002.
[PJNO97] Simon L. Peyton Jones, Thomas Nordin, and Dino Oliva. C- - : A portable assembly
language. In Chris Clack, Kevin Hammond, and Antony J. T. Davie, editors,
Implementation of Functional Languages, volume 1467 of Lecture Notes in Computer
Science, pages 1–19. Springer, 1997.
[PTS+11] Nicolas Palix, Gaël Thomas, Suman Saha, Christophe Calvès, Julia Lawall, and
Gilles Muller. Faults in Linux : Ten years later. In Sixteenth International Conference
on Architectural Support for Programming Languages and Operating Systems
(ASPLOS 2011), 2011.
[Ric53] H. G. Rice. Classes of recursively enumerable sets and their decision problems.
Transactions of the American Mathematical Society, 74(2) :pp. 358–366, 1953.
[RV98] Didier Rémy and Jérôme Vouillon. Objective ML : An effective object-oriented
extension to ML. In Theory And Practice of Object Systems, pages 27–50, 1998.
[Sim03] Vincent Simonet. Flow Caml in a nutshell. In Graham Hutton, editor, Proceedings
of the first APPSEM-II workshop, pages 152–165, 2003.
[SLM11] Suman Saha, Julia Lawall, and Gilles Muller. An approach to improving the structure
of error-handling code in the linux kernel. In Proceedings of the 2011 SIGPLAN/SIGBED
conference on Languages, compilers and tools for embedded systems,
LCTES ’11, pages 41–50. ACM, 2011.170 BIBLIOGRAPHIE
[SM03] Andrei Sabelfeld and Andrew C. Myers. Language-based information-flow security.
IEEE Journal on Selected Areas in Communications, 21 :2003, 2003.
[Spe05] Brad Spengler. grsecurity 2.1.0 and kernel vulnerabilities. Linux Weekly News,
2005.
[SRH96] Mooly Sagiv, Thomas Reps, and Susan Horwitz. Precise interprocedural data-
flow analysis with applications to constant propagation. In Selected papers from
the 6th international joint conference on Theory and practice of software development,
TAPSOFT ’95, pages 131–170. Elsevier Science Publishers B. V., 1996.
[Sta11] Basile Starynkevitch. Melt - a translated domain specific language embedded
in the gcc compiler. In Olivier Danvy and Chung chieh Shan, editors, DSL, volume
66 of EPTCS, pages 118–142, 2011.
[STFW01] Umesh Shankar, Kunal Talwar, Jeffrey S. Foster, and David Wagner. Detecting
format string vulnerabilities with type qualifiers. In SSYM’01 : Proceedings of the
10th conference on USENIX Security Symposium, page 16. USENIX Association,
2001.
[SY86] R E Strom and S Yemini. Typestate : A programming language concept for enhancing
software reliability. IEEE Trans. Softw. Eng., 12(1) :157–171, 1986.
[Tan07] Andrew S. Tanenbaum. Modern Operating Systems. Prentice Hall Press, 3rd edition,
2007.
[The04] The Coq Development Team. The Coq Proof Assistant Reference Manual – Version
V8.0, 2004. http://coq.inria.fr.
[TJ92] Jean-Pierre Talpin and Pierre Jouvelot. Polymorphic type, region and effect inference.
Journal of Functional Programming, 2 :245–271, 1992.
[TT94] Mads Tofte and Jean-Pierre Talpin. Implementation of the typed call-by-value
λ-calculus using a stack of regions. In Proceedings of the 21st ACM SIGPLANSIGACT
symposium on Principles of programming languages, POPL ’94, pages
188–201. ACM, 1994.
[VB04] Arnaud Venet and Guillaume Brat. Precise and efficient static array bound checking
for large embedded c programs. In Proceedings of the 2004 ACM SIGPLAN
conference on Programming language design and implementation, PLDI
’04, pages 231–242. ACM, 2004.
[vL11] Twan van Laarhoven. Lenses : viewing and updating data structures in Haskell.
http://www.twanvl.nl/files/lenses-talk-2011-05-17.pdf, 2011.Résumé
Les noyaux de systèmes d’exploitation manipulent des données fournies par les programmes utilisateur
via les appels système. Si elles sont manipulées sans prendre une attention particulière, une faille de sécurité
connue sous le nom de Confused Deputy Problem peut amener à des fuites de données confidentielles ou
l’élévation de privilèges d’un attaquant.
Le but de cette thèse est d’utiliser des techniques de typage statique afin de détecter les manipulations
dangereuses de pointeurs contrôlés par l’espace utilisateur.
La plupart des systèmes d’exploitation sont écrits dans le langage C. On commence par en isoler un
sous-langage sûr nommé SAFESPEAK. Sa sémantique opérationnelle et un premier système de types sont
décrits, et les propriétés classiques de sûreté du typage sont établies. La manipulation des états mémoire est
formalisée sous la forme de lentilles bidirectionnelles, qui permettent d’encoder les mises à jour partielles
des états et variables. Un première analyse sur ce langage est décrite, permettant de distinguer les entiers
utilisés comme bitmasks, qui sont une source de bugs dans les programmes C.
On ajoute ensuite à SAFESPEAK la notion de valeur provenant de l’espace utilisateur. La sûreté du typage
est alors brisée, mais on peut la réétablir en donnant un type particulier aux pointeurs contrôlés par l’espace
utilisateur, ce qui force leur déférencement à se faire de manière contrôlée. Cette technique permet
de détecter deux bugs dans le noyau Linux : le premier concerne un pilote de carte graphique AMD, et le
second l’appel système ptrace sur l’architecture Blackfin.
Abstract
Operating system kernels need to manipulate data that comes from user programs through system calls.
If it is done in an incautious manner, a security vulnerability known as the Confused Deputy Problem can
lead to information disclosure or privilege escalation.
The goal of this thesis is to use static typing to detect the dangerous uses of pointers that are controlled
by userspace.
Most operating systems are written in the C language. We start by isolating SAFESPEAK, a safe subset of it.
Its operational semantics as well as a type system are described, and the classic properties of type safety are
established. Memory states are manipulated using bidirectional lenses, which can encode partial updates
to states and variables. A first analysis is described, that identifies integers used as bitmasks, which are a
common source of bugs in C programs.
Then, we add to SAFESPEAK the notion of pointers coming from userspace. This breaks type safety, but
it is possible to get it back by assigning a different type to the pointers that are controlled by userspace. This
distinction forces their dereferencing to be done in a controlled fashion. This technique makes it possible to
detect two bugs in the Linux kernel : the first one is in a video driver for an AMD video card, and the second
one in the ptrace system call for the Blackfin architecture.
Analyse de mod`eles g´eom´etriques d’assemblages pour les
structures et les enrichir avec des informations
fonctionnelles
Ahmad Shahwan
To cite this version:
Ahmad Shahwan. Analyse de mod`eles g´eom´etriques d’assemblages pour les structures et les
enrichir avec des informations fonctionnelles. Other. Universit´e de Grenoble, 2014. French.
.
HAL Id: tel-01071650
https://tel.archives-ouvertes.fr/tel-01071650
Submitted on 6 Oct 2014
HAL is a multi-disciplinary open access
archive for the deposit and dissemination of scientific
research documents, whether they are published
or not. The documents may come from
teaching and research institutions in France or
abroad, or from public or private research centers.
L’archive ouverte pluridisciplinaire HAL, est
destin´ee au d´epˆot et `a la diffusion de documents
scientifiques de niveau recherche, publi´es ou non,
´emanant des ´etablissements d’enseignement et de
recherche fran¸cais ou ´etrangers, des laboratoires
publics ou priv´es.THESE `
Pour obtenir le grade de
DOCTEUR DE L’UNIVERSITE DE GRENOBLE ´
Specialit ´ e : ´ Mathematiques et Informatique ´
Arretˆ e minist ´ eriel : 7 ao ´ ut 2006 ˆ
Present ´ ee par ´
Ahmad SHAHWAN
These dirig ` ee par ´ Jean-Claude LEON ´
et codirigee par ´ Gilles Foucault
prepar ´ ee au sein ´ G-SCOP, Grenoble-INP
et de MSTII
Processing Geometric Models
of Assemblies to Structure and
Enrich them with Functional
Information
These soutenue publiquement le ` 29 ao ˆut 2014,
devant le jury compose de : ´
M., John GERO
Professor, University of North Carolina, USA, Rapporteur
M., Marc DANIEL
Professeur, Universite Aix-Marseille, Rapporteur ´
Mme, Marie-Christine ROUSSET
Professeur, Universite Joseph Fourier, Examinateur ´
M., Jean-Philippe PERNOT
Professeur, Arts et Metiers ParisTech, Examinateur ´
M., Jean-Claude LEON
Professeur, Grenoble-INP, Directeur de these `
M., Gilles FOUCAULT
Maˆıtre de Conferences, Universit ´ e Joseph Fourier, Co-Directeur de th ´ ese `Processing Geometric Models of Assemblies
to Structure and Enrich them with
Functional Information
Traitement de mod`eles g´eom´etriques
d’assemblages afin de les structurer et de les
enrichir avec des informations fonctionnellesiii
abstract
The digital mock-up (DMU) of a product has taken a central position
in the product development process (PDP). It provides the geometric
reference of the product assembly, as it defines the shape of each individual
component, as well as the way components are put together.
However, observations show that this geometric model is no more than
a conventional representation of what the real product is. Additionally,
and because of its pivotal role, the DMU is more and more required
to provide information beyond mere geometry to be used in different
stages of the PDP. An increasingly urging demand is functional
information at different levels of the geometric representation of the
assembly. This information is shown to be essential in phases such as
geometric pre-processing for finite element analysis (FEA) purposes.
In this work, an automated method is put forward that enriches a
geometric model, which is the product DMU, with function information
needed for FEA preparations. To this end, the initial geometry
is restructured at different levels according to functional annotation
needs. Prevailing industrial practices and representation conventions
are taken into account in order to functionally interpret the pure geometric
model that provides a starting point to the proposed method.
r´esum´e
La maquette num´erique d’un produit occupe une position centrale
dans le processus de d´eveloppement de produits. Elle est utilis´ee comme
repr´esentations de r´ef´erence des produits, en d´efinissant la forme g´eom´etrique
de chaque composant, ainsi que les repr´esentations simplifi´ees
des liaisons entre composants. Toutefois, les observations montrent
que ce mod`ele g´eom´etrique n’est qu’une repr´esentation simplifi´ee
du produit r´eel. De plus, et grˆace `a son rˆole cl´e, la maquette num´erique
est de plus en plus utilis´ee pour structurer les informations non-g´eom´etriques
qui sont ensuite utilis´ees dans diverses ´etapes du processus
de d´eveloppement de produits. Une exigence importante est d’acc´eder
aux informations fonctionnelles `a diff´erents niveaux de la repr´esentations
g´eom´etrique d’un assemblage. Ces informations fonctionnelles
s’av`erent essentielles pour pr´eparer des analyses ´el´ements finis. Dans
ce travail, nous proposons une m´ethode automatis´ee afin d’enrichir
le mod`ele g´eom´etrique extrait d’une maquette num´erique avec les informations
fonctionnelles n´ecessaires pour la pr´eparation d’un mod`ele
de simulation par ´el´ements finis. Les pratiques industrielles et les repr´esentations
g´eom´etriques simplifi´ees sont prises en compte lors de
l’interpr´etation d’un mod`ele purement g´eom´etrique qui constitue le
point de d´epart de la m´ethode propos´ee.Scientific Communications
Accepted
• Shahwan, A., Leon, J.-C., Foucault, G., and Fine, L. ´ Functional
restructuring of CAD models for FEA purposes. Engineering
Computations (2014).
Articles
• Boussuge, F., Shahwan, A., Leon, J.-C., Hahmann, S., Fou- ´
cault, G., and Fine, L. Template-based geometric transformations
of a functionally enriched DMU into FE assembly models. ComputerAided
Design and Applications 11, 04 (2014), 436–449.
• Shahwan, A., Leon, J.-C., Foucault, G., Trlin, M., and Palombi, ´
O. Qualitative behavioral reasoning from components’ interfaces to
components’ functions for DMU adaption to fe analyses. ComputerAided
Design 45, 2 (2013), 383–394.
In proceedings
• Shahwan, A., Foucault, G., Leon, J.-C., and Fine, L. ´ Deriving
functional properties of components from the analysis of digital mockups.
In Tools and Methods of Competitive Engineering (Karlsruhe,
Germany, 2012).
• Li, K., Shahwan, A., Trlin, M., Foucault, G., and Leon, J.- ´
C. Automated contextual annotation of B-Rep CAD mechanical components
deriving technology and symmetry information to support
partial retrieval. In Eurographics Workshop on 3D Object Retrieval
(Cagliari, Italy, 2012), pp. 67–70.Contents
Acronyms xiii
Introduction xv
1 DMU and Polymorphic Representation 1
1.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.2 Product model . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.3 Product prototype . . . . . . . . . . . . . . . . . . . . . . . . 4
1.4 Product digital mock-up . . . . . . . . . . . . . . . . . . . . . 7
1.4.1 Computerized product models . . . . . . . . . . . . . 7
1.4.2 DMUs as models and prototypes at a time . . . . . . 8
1.5 Geometric models and modeling methods . . . . . . . . . . . 9
1.5.1 Geometric validity and the quality of a DMU . . . . . 10
1.5.2 Discrete geometric models . . . . . . . . . . . . . . . . 13
1.5.3 Analytical geometric models . . . . . . . . . . . . . . . 16
1.6 DMU as an assembly . . . . . . . . . . . . . . . . . . . . . . . 18
1.6.1 DMU structure . . . . . . . . . . . . . . . . . . . . . . 19
1.6.2 Components’ positioning . . . . . . . . . . . . . . . . . 20
1.7 Other information associated to a DMU . . . . . . . . . . . . 25
1.8 Application of DMU . . . . . . . . . . . . . . . . . . . . . . . 27
1.9 Basic principles of finite element analyses . . . . . . . . . . . 28
1.9.1 Numerical approximations of physical phenomena . . 28
1.9.2 Generation of a FEM . . . . . . . . . . . . . . . . . . 30
1.10 DMU as polymorphic representation of a product . . . . . . . 30
1.11 Adapted definition of DMU . . . . . . . . . . . . . . . . . . . 31
1.12 Conclusions . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
2 Literature Overview 35
2.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
2.2 Function formalization . . . . . . . . . . . . . . . . . . . . . . 36
2.3 Connections between form, behavior, and function . . . . . . 38
2.3.1 Behavior to complete the design puzzle . . . . . . . . 38
2.3.2 Pairs of interacting interfaces . . . . . . . . . . . . . . 38viii Contents
2.3.3 Tools and guidelines to support the design process . . 39
2.4 Constructive approaches to deduce function . . . . . . . . . . 41
2.4.1 Form feature recognition . . . . . . . . . . . . . . . . . 42
2.4.2 Functionality as a result of geometric interactions . . . 44
2.5 Geometric analysis to detect interactions . . . . . . . . . . . . 46
2.5.1 Geometric interaction detection . . . . . . . . . . . . . 46
2.5.2 Importance of a unique geometric representation . . . 47
2.6 CAD and knowledge representation . . . . . . . . . . . . . . . 48
2.6.1 Domain knowledge and model knowledge . . . . . . . 49
2.6.2 Ontologies as an assembly knowledge storehouse . . . 49
2.6.3 Knowledge-based engineering approaches . . . . . . . 53
2.7 From CAD to FEA . . . . . . . . . . . . . . . . . . . . . . . . 55
2.7.1 Pre-processing at the core of the FEM . . . . . . . . . 55
2.7.2 Direct geometric approaches . . . . . . . . . . . . . . . 55
2.8 Conclusions . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58
3 Functional Semantics: Needs and Objectives 61
3.1 Taking 3D models beyond manufacturing purposes . . . . . . 61
3.2 Differences between digital and real shapes . . . . . . . . . . 63
3.3 Enabling semi-automatic pre-processing . . . . . . . . . . . . 65
3.3.1 Pre-processing tasks . . . . . . . . . . . . . . . . . . . 65
3.3.2 Pre-processing automation requirements . . . . . . . . 66
3.4 Bridging the gap with functional knowledge . . . . . . . . . . 67
3.5 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70
4 Functional Restructuring and Annotation 73
4.1 Qualitative bottom-up approach . . . . . . . . . . . . . . . . 74
4.2 Common concepts . . . . . . . . . . . . . . . . . . . . . . . . 74
4.2.1 Function as the semantics of design . . . . . . . . . . . 75
4.2.2 Functional Interface . . . . . . . . . . . . . . . . . . . 76
4.2.3 Functional Designation . . . . . . . . . . . . . . . . . 78
4.2.4 Functional Cluster . . . . . . . . . . . . . . . . . . . . 81
4.2.5 Conventional Interface . . . . . . . . . . . . . . . . . . 85
4.2.6 Taxonomies . . . . . . . . . . . . . . . . . . . . . . . . 87
4.3 Method walk-through . . . . . . . . . . . . . . . . . . . . . . 91
4.4 Conclusions . . . . . . . . . . . . . . . . . . . . . . . . . . . . 93
5 Functional Geometric Interaction 95
5.1 Functional surfaces . . . . . . . . . . . . . . . . . . . . . . . . 95
5.2 Geometric preparation and rapid detection of interactions . . 97
5.2.1 Geometric model as global input . . . . . . . . . . . . 97
5.2.2 Maximal edges and surfaces . . . . . . . . . . . . . . . 98
5.2.3 Geometric interaction detection . . . . . . . . . . . . . 99
5.2.4 Local coordinate systems . . . . . . . . . . . . . . . . 103Contents ix
5.2.5 Conventional interface graph . . . . . . . . . . . . . . 104
5.3 Precise detection of interaction zones . . . . . . . . . . . . . . 107
5.4 Form-functionality mapping . . . . . . . . . . . . . . . . . . . 108
5.4.1 Multiple functional interpretation . . . . . . . . . . . . 109
5.5 Conclusions . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
6 Qualitative Behavioral Analysis 111
6.1 Behavioral study to bind form to functionality . . . . . . . . 112
6.2 Reference states . . . . . . . . . . . . . . . . . . . . . . . . . . 112
6.3 Qualitative representation of physical properties . . . . . . . 114
6.3.1 Qualitative physical dimension . . . . . . . . . . . . . 115
6.3.2 Algebraic structure of qualitative values . . . . . . . . 121
6.3.3 Coordinate systems alignment . . . . . . . . . . . . . . 123
6.4 Reference state I: Static equilibrium . . . . . . . . . . . . . . 124
6.4.1 Static equilibrium equations . . . . . . . . . . . . . . . 124
6.4.2 Graph search to eliminate irrelevant FIs . . . . . . . . 125
6.4.3 Local failure of functional interpretation . . . . . . . . 129
6.4.4 Graph search example . . . . . . . . . . . . . . . . . . 129
6.5 Reference state II: Static determinacy . . . . . . . . . . . . . 130
6.5.1 Statically indeterminate configurations . . . . . . . . . 133
6.5.2 Force propagation and force propagation graphs . . . 137
6.6 Reference state III: Assembly joint with threaded link . . . . 138
6.6.1 Detection of force propagation cycles . . . . . . . . . . 139
6.7 Conclusions . . . . . . . . . . . . . . . . . . . . . . . . . . . . 142
7 Rule-based Reasoning 145
7.1 Knowledge at the functional unit level . . . . . . . . . . . . . 146
7.2 Inference rules as domain knowledge . . . . . . . . . . . . . . 146
7.3 Reasoning alternatives . . . . . . . . . . . . . . . . . . . . . . 148
7.3.1 Dynamic formalization of domain specific rules . . . . 148
7.3.2 Problem decidability . . . . . . . . . . . . . . . . . . . 149
7.4 DMU knowledge representation . . . . . . . . . . . . . . . . . 150
7.4.1 Ontology definition through its concepts and roles . . 151
7.4.2 Ontology population with model knowledge . . . . . . 152
7.5 Formal reasoning to complete functional knowledge . . . . . . 154
7.5.1 Inference rules in DL . . . . . . . . . . . . . . . . . . . 155
7.5.2 The unique name assumption . . . . . . . . . . . . . . 157
7.5.3 The open world assumption . . . . . . . . . . . . . . . 158
7.5.4 Integration of DL reasoners . . . . . . . . . . . . . . . 159
7.6 Conclusions . . . . . . . . . . . . . . . . . . . . . . . . . . . . 163x Contents
8 Results and Comparative Study 165
8.1 Application architecture . . . . . . . . . . . . . . . . . . . . . 165
8.2 Application to industrial examples . . . . . . . . . . . . . . . 167
8.3 Integration with FEA pre-processors . . . . . . . . . . . . . . 173
8.4 Conclusions . . . . . . . . . . . . . . . . . . . . . . . . . . . . 175
9 Conclusions and Perspectives 177
9.1 Conclusions . . . . . . . . . . . . . . . . . . . . . . . . . . . . 177
9.2 Perspectives . . . . . . . . . . . . . . . . . . . . . . . . . . . . 180
A Fit Tolerancing and Dimensioning 183
B Dual Vectors 187
C Screw Theory 189
D Description Logic 193List of Definitions
1.1 Definition (Product model) . . . . . . . . . . . . . . . . . . . 2
1.2 Definition (Product prototype) . . . . . . . . . . . . . . . . . 4
1.3 Definition (Digital mock-up) . . . . . . . . . . . . . . . . . . . 32
4.1 Definition (Function) . . . . . . . . . . . . . . . . . . . . . . . 75
4.2 Definition (Functional Interface) . . . . . . . . . . . . . . . . 77
4.3 Definition (Functional Designation) . . . . . . . . . . . . . . . 78
4.4 Definition (Functional Cluster) . . . . . . . . . . . . . . . . . 81
4.5 Definition (Conventional Interface) . . . . . . . . . . . . . . . 87
4.6 Definition (Taxonomy) . . . . . . . . . . . . . . . . . . . . . . 88
5.1 Definition (Conventional interface graph) . . . . . . . . . . . 104
5.2 Definition (Functional interpretation) . . . . . . . . . . . . . 109
6.1 Definition (Reference state) . . . . . . . . . . . . . . . . . . . 112
6.2 Definition (Force Propagation Graph) . . . . . . . . . . . . . 137
B.1 Definition (General dual number ring) . . . . . . . . . . . . . 187
B.2 Definition (General dual number semi-ring) . . . . . . . . . . 188
C.1 Definition (Screw) . . . . . . . . . . . . . . . . . . . . . . . . 189
C.2 Definition (Reciprocal screws) . . . . . . . . . . . . . . . . . . 191List of Hypotheses
5.1 Hypothesis (Functional surfaces) . . . . . . . . . . . . . . . . 96
6.1 Hypothesis (Rigid bodies) . . . . . . . . . . . . . . . . . . . . 113
6.2 Hypothesis (Conservative systems) . . . . . . . . . . . . . . . 114
6.3 Hypothesis (Mechanical interactions) . . . . . . . . . . . . . . 114
6.4 Hypothesis (Static equilibrium) . . . . . . . . . . . . . . . . . 124
6.5 Hypothesis (Static determinacy) . . . . . . . . . . . . . . . . 134
6.6 Hypothesis (Force propagation) . . . . . . . . . . . . . . . . . 139Acronyms
AI Artificial Intelligence.
API Application Programming Interface.
ARM Assembly Relation Model.
B-Rep Boundary representation.
BOM Bill of materials.
C&CM Contact and Channel Model.
CAD Computer Aided Design.
CAM Computer Aided Manufacturing.
CAPP Computer Aided Process Planning.
CI Conventional interface.
CIG Conventional interface graph.
CIuG Conventional interface underling undirected
graph.
CNC Computer Numeric Control.
CPM Core Product Model.
CSG Constructive Solid Geometry.
CWA Closed World Assumption.
DIG DL Implementation Group.
DL Description Logic.
DMU Digital mock-up.
DoF Degree of freedom.
FBS Function-Behavior-Structure.
FC Functional cluster.
FD Functional designation.
FE Finite element.
FEA Finite element analysis.
FEM Finite Element Model.
FI Functional interface.
FOL First Order Logic.xvi Acronyms
FPG Force propagation graph.
FR Feature Recognition.
GARD Generic Assembly Relationship Diagram.
GCI General Concept Inclusion.
GD&T Geometric Dimensioning and Tolerancing.
GPU Graphics Processing Unit.
KBE Knowledge Based Engineering.
KRR Knowledge Representation and Reasoning.
MDB Model-Based Definition.
NURBS Non-Uniform Rational B-Spline.
OAM Open Assembly Model.
OIL Ontology Interchange Language.
OWA Open World Assumption.
OWL Web Ontology Language.
PDP Product development process.
PFM Part Function Model.
PLM Product Lifecycle Management.
RDF Resource Description Framework.
RDF-S Resource Description Framework Scheme.
RS Reference state.
SPARQL SPARQL Protocol and RDF Query Language.
SSWAP Simple Semantic Web Architecture and Protocol.
UNA Unique Name Assumption.
URI Uniform Resource Identifier.
VR Virtual Reality.
W3C World Wide Web Consortium.
XML Extensible Markup Language.Introduction
When designing an artifact that is identified by its functionality, it is a
common practice to decompose the artifact in question into components,
each satisfying a well-defined set of functions that, put together, lead to the
satisfaction of the desired functionality of the designed artifact.
In the industrial context, components happen to be physical objects, de-
fined by their shapes and materials that decide their physical properties and
behaviors. In order for an object to deliver a precise function, its shape has
to be carefully engineered. The 3D shape of the object dictates its interactions
with its environment, i.e., its neighboring components, its neighboring
products or a neighboring human being. These interactions define its behavior,
thus its functionality.
Because of this pivotal importance of components shapes to deliver their
functions, tools were provided and conventions established to enable the
production and communication of shape design models as part of the product
development process (PDP). This emphasis is a natural outcome of a shapeoriented
design process. Design intentions, however, are not clearly reflected
in design models, in spite of their clear presence in engineers’ minds during
the design process. In fact, no robust tools or agreed-upon conventions exist
to link a particular design with its rationale.
This observation used to be less pronounced at the time blueprints were
used to define design models. Blueprints are 2D drawings that aim at unambiguously
defining the shape of an object. They have been in use for
so long that conventions converged toward globally understood agreements,
and standards were put to govern such conventions [16, 183]. Nevertheless,
the advent of Computer Aided Design (CAD) systems in early 80s soon
provided designers with another geometric dimension that would remarkably
influence industrial standards and conventions. 3D solid modelers prevailed
as a natural choice for product design, engineers shifted to producing
3D models instead of traditional technical drawings, and mechanical components
became dominantly represented as 3D objects in today’s models.
This gave birth to the concept of digital mock-up (DMU), which gathered
the representation of components of a product assembly in one geometric
model.
Efforts were paid to centralize the product knowledge in one place, andxviii Introduction
the DMU was suggested as a natural candidate as it geometrically defines the
product. In spite of attempts to homogenize and standardize the representations
of non-geometric knowledge [12], defined standards are still poorly
implemented in industrial practices because commercial software products
are far from exploiting these standards. In fact, an industrial DMU, as currently
available, is no more than a conventional geometric representation of
a product assembly. A DMU can at best contain loose textual annotations,
which may be interpreted within an organization or a working group, if at
all interpretable. This is partially because a textual annotation does not
relate precisely to a geometric subset of a component or an assembly.
The need of design intentions, however, remained paramount, if the DMU
is to be fully exploited in the PDP, and utilized beyond Computer Aided
Manufacturing (CAM) applications. In fact, this knowledge is still being
mined from geometric models of a product to feed applications such as geometric
pre-processing of assembly models for simulation purposes. This is
particularly the case of mechanical simulations where the structural behavior
is a key issue that is commonly addressed using numerical methods such
as the Finite Element Model (FEM). However, and due to conventional
representations of functional and technological information in the DMU,
the model preparation task for FEM is still mainly manual and resource
intensive. This is particularly true for complex products like aircraft structures
[1].
The user-intensive functional annotation of a DMU introduces a bottleneck
into today’s highly automated PDP. In order to accelerate product
development, an automated method should be established that enables the
extraction of relevant functional information out of pure geometric representation
of product assembly. Function is a key concept for designers and
engineers that closely relates to the design activity and, hence, to the socalled
design intent [107]. Consequently, it is highly important to provide
engineers and designers with this functional information tightly connected
with 3D component models, so that they can efficiently process them during
the PDP. Furthermore, the desired approach should take into account
mainstream industrial practices and conventions when interpreting geometric
models.
In this work, the focus is placed on the application to structural behavior
of a product or, more precisely, of an assembly of components. The
proposed method is an enrichment process that mainly aims at a seamless
integration with geometric preprocessors for finite element (FE) simulation
purposes, even though other applications can also be envisaged. In order for
this method to provide an adequate input to finite element analysis (FEA)
applications functional annotations and component denominations should
be made in tight connection to precise geometric entities that they describe.
To this end, geometry processing and reasoning mechanisms applied to mechanical
behaviors are set up to adequately structure geometric models ofxix
assemblies.
In the rest of this document, Chapter 1 provides an introductory presentation
of industrial concepts that relate to our work. It particularly presents
what can be expected from an industrial DMU nowadays. Literature and
work related to the proposed method is reviewed and analyzed in Chapter 2.
Chapter 3 sheds more light on the motivation of our work, and the role that
the proposed method plays in an efficient PDP. Chapter 4 defines concepts
and terminology that are used across this document and upon which the
proposed method is founded. It also provides an overview of this method
before later chapters develop further on each stage.
Chapter 5 develops in more details the geometric analysis of the input
model, which is the pure geometric model of a DMU. This chapter shows
how interactions between components are reconstituted on a geometric basis
and how functional interpretations can be assigned to each of them. At this
stage, the shape – function relationship cannot be unambiguously recovered.
Chapter 6 then provides the means to functionally interpret those interactions
in an unambiguous way, through a qualitative behavioral analysis of
the model. This is algorithmic approach achieved through the tight dependencies
between shape, function and behavior that produce a unique relation
between shape and function for the interactions between components. The
concept of reference states is then used to synthesize some component behavior
through their interactions in order to reject irrelevant configurations,
thus removing ambiguities. Further qualitative behavioral information is
derived too.
Chapter 7 completes the functional picture of the assembly using domain
specific rules and taking the functional interpretation beyond the interaction
level, toward the functional unit level, using the effective relationship
between shape and function at the interface level and the newly derived behavioral
information at the component and component cluster levels. It is
an inference-based reasoning approach that can be adapted to the conventional
representations of assemblies and meet the current practices observed
in industry.
Once the input model is geometrically restructured, and functionally
annotated, it is made available as input for FEA preprocessors. Chapter 8
shows results of the application of the proposed method on examples varying
from illustrative models to industrial scale DMUs. The same chapter
also shows how the method successfully lends hand to a template-based geometric
preprocessor, generating simulation models that correspond to the
simulation objectives. Chapter 9 concludes this document, exploring potentials
of future work to extend the proposed method and its application.Chapter 1
Digital Mock-Up and the
Polymorphic Representation
of Assemblies
DMUs constitute a starting point to our research. Thus, it is indispensable
to present basic concepts and definitions that are central
to this work before detailing our approach. Those concepts are
presented from different viewpoints according to the literature and
to industrial practices, before an adaption of these concepts to our
context is underlined. An analysis of a DMU content also shows
how it can refer not only to a single representation of an assembly
but to a polymorphic one.
1.1 Introduction
In this chapter we provide a general understanding of a DMU, a concept
which is central to our research. Then, we formally define this concept as
it applies to the current work. To this end, we first present closely related
notions that pave the way to the conceptualization of a DMU. We also show
what kind of information it holds, and how this information is represented.
Sections 1.2 and 1.3 demonstrate and distinguish two concepts: the product
model and product prototypes. Though these terminologies are used interchangeably
across literature, we clearly make the distinction according to
our understanding, and to the context of this work. Then, the concept of
DMU is analyzed in the following sections to address its representation from
a geometric point of view as well as from a more technological point of view
through the concept of assembly. This leads to the analysis of the effective
content of an assembly and its relationship with a DMU. Subsequently, the
generation of Finite Element Analyses from a DMU is outlined to illustrate
into which extent a DMU can contribute to define a Finite Element Model.2 Chapter 1. DMU and Polymorphic Representation
This finally leads to the concept of polymorphism of an assembly.
1.2 Product model
In the context of product development, manufacturing processes of this product
must be precisely defined, so that the resulting product matches its initial
requirements, i.e., the product meets designers’ and users’ expectations.
To this end, models are used to define the product in enough details as the
outcome of an unambiguous and overall manufacturing process.
Models consist of documents and schemes that describe the product, often
visually. They often use common languages and annotations to refer
to resources (materials, quantities, etc.) and processes (parameters, dimensions,
units, etc.). Those annotations should be standardized, or at least
agreed upon among people involved in the production process. Otherwise a
model can be misinterpreted.
Definition 1.1 (Product model). A product model is a document, or a set
of documents, that uniquely define the manufacturing process of a product
in compliance with its specifications [43, 140].
In this sense, models can be viewed as cookbooks showing how to produce
instances of the product that conform to the same specifications. Models
are closely related the production process for the following reasons:
• To persistently capture the know-how of the production process. In the
absence of such documentations, the manufacturing knowledge is only
present in engineers’ minds, making this process highly dependent on
the availability of experts. Models capture this knowledge and reduce
the risk associated with such dependency;
• To formally define the manufacturing process, leaving no room for ambiguity
and multiple interpretations. This formality allows for the reproduction
of identical instances of the same ‘pattern’, avoiding undesirable
surprises due to miscommunication or improvisation of incomplete
specifications. Otherwise, divergence in the final product may
drift it away form initial requirements;
• To allow tracking of and easy adaption to requirements. A product
(or its prototype, as shown in Section 1.3) still may fail to fulfill the
desired requirement. In this case, the product should be re-engineered.
The existence of a model allows engineers to perform more easily modifications
that can be directly mapped to the product characteristics
to be amended. Another case when the product, thus its model, is to
be re-engineered is when the requirements evolve, which is likely to
happen in almost all industries.Product model 3
(a) (b)
Figure 1.1: Examples of partial product model: (a) architectural blueprints;
(b) software diagram.
Product models existed quite early in different engineering domains such
as architecture, mechanics, electrics, electronics, and computer software,
among others. These models were not digital until a couple of decades ago.
Depending on the discipline, some subsets of these models can be referred to
as technical drawings, blueprints, draftings, diagrams, etc. Figure 1.1 shows
examples of such models in different disciplines and applications.
As pointed out earlier, the current concept of product model focuses
essentially on manufacturing issues and does neither incorporate properties
that ensure the consistency between its set of documents and the product
obtained nor cover some parts of the product design process.
Product model in the field of mechanical engineering
When it comes to mechanical engineering, product models were traditionally
referred to as technical drawings or drafting. In fact, those are 2D drawings
that represents either a projection of the product onto a given plane according
to a given orientation (usually perpendicular to the plane and aligned
with a reference direction) and/or a cross section into the product components(s)
[70]. These drawings form a part of the product model.
As Figure 1.2 shows, precise annotations are used to augment those drawings
with complementary information such as Geometric Dimensioning and
Tolerancing (GD&T), some of which cannot be geometrically represented
on a sheet. Such information is mandatory to allow people manufacturing
the product [124]. This figure shows in red, shaft/housing tolerancing and
dimensioning symbols explained further in Appendix A. Projection, cross-4 Chapter 1. DMU and Polymorphic Representation
Table 1.1: Geometric tolerancing reference chart as per ASME Y14.5 – 1982.
straightness planarity cicularity
cylindricity line profile surface profile
perpendicularity angularity parallelism
symmetry position concentricity
sectioning and annotations follow agreed-upon conventions that make the
model as unambiguous as possible to a knowledgeable reader. Table 1.1
show standard dimensioning and tolerancing symbols as defined by ASME
Y14.5 – 1982 [183].
1.3 Product prototype
It is preferable that design defects be outlined as early as possible in the
product live cycle. More specifically, it is of high advantage that a shortcoming
be reported before a real instance of a product is manufactured and
machined. This is due to the high cost of machining and other manufacturing
processes. If compliance tests are to be run directly on the real product,
without any previous test on some sort of a “dummy” version of it, a considerable
risk is involved since the product is likely to be re-engineered. The
manufacturing and machining costs can be nearly doubled at each iteration.
To this end, a prototype, close enough in its behaviors to the real product
but with reduced production costs, is produced first. Then, different
tests are run against this prototype to assess its conformity to different requirements
and detect potential deficiencies. Whenever such shortcomings
are revealed, the product model is adapted accordingly, generating a new
prototype. Then, the process is repeated until the prototype is validated by
all tests. This can be seen as an iterative process of modeling, prototyping,
and evaluation. Subsequently, the product is progressively refined through
multiple iterations.
Definition 1.2 (Product prototype). A product prototype is a dummy representation
of a real product, that is meant to emulate it in one or more
aspects. It is used to assess or predict certain behaviors and/or interactions
[160, 184].
Prototypes can emulate the product functionally, aesthetically, physically,
ergonomically, etc. depending on the intended assessment planned on
the prototype. Prototypes are vital in an efficient production process for
the following reasons:
• To allow early recognition of deficiencies. The earlier the deficiencies
are detected, the lower the amendment cost is, since fewer stages are
wasted and redone;Product prototype 5
Ø50H7
Ø41H7
Ø15p6
Ø69H8f7
Ø13H7g6
Figure 1.2: Blueprint of a mechanical product, showing a cross-section (top)
and a projection (bottom). The projection also shows the cutting plane
of the cross-section. The drawing shows in red, shaft/housing tolerance
annotations.6 Chapter 1. DMU and Polymorphic Representation
• To allow testing on life-critical products. Some highly critical industries,
such as aeronautics, tolerate little or no failure once the product
is put to operation. Errors can be fatal at this stage. Thus, prototypes
allowing virtual tests on the product are necessary in such cases;
• To help decision making. Studies show that decisions taken at early
stages of a design process are highly expensive [47, 180]. However,
oftentimes product behavior and the impact that it may entail cannot
be precisely predicted at those stages. Prototypes enables engineers to
do a sort of what-if analyses, and benefit form their feedbacks before
taking a final decision about the product design.
More recently, product prototypes evolved toward digital or virtual ones,
which reduces further a product development process and can be achieved
using digital simulations.
It is worth noticing that tests run against prototypes do not replace
quality and compliance tests that should be run on the real product. Product
prototypes are just mock-ups, they emulate the products behavior to a
certain extent, but not exactly.
Prototypes are used in different engineering disciplines. In architecture
and interior design scaled-down prototypes are used to give a global perspective
of the structure before it is actually implemented (see Figure 1.3a).
Architecture prototypes are often used for aesthetic and ergonomic assessments.
In software engineering, incomplete versions of the software that fulfill
certain requirements are implemented first to satisfy unit tests. Unit testing
is in the core of software engineering best practices to avoid bulk debugging.
Usually, one module of the software is tested at a time, with the rest replaced
by mock modules.
Product prototype in the field of mechanical engineering
Approximate replicas of a mechanical product may be built to assess its
ease of use, functionality, structural behavior, and so on. Those replicas are
prototypes that are very similar to the designed product (see Figure 1.3a).
However, a product and its prototype differ in how and from what each
is made. Materials of the real product are usually costly, thus prototypes are
built out of cheaper materials that have similar physical properties according
to the intended tests. Moreover, the manufacturing and machining processes
of the real product are often expensive as well, partially due to the choice
for materials. Then, prototypes are crafted using different methods that
reduce costs, keeping the final shapes as close as possible to the original
design [160].
Figure 1.4 shows a prototype of a hand navigator [49]. The prototype is
made of thermoplastic powder shaped by means of selective heat sintering.Product digital mock-up 7
(a) (b)
Figure 1.3: Examples of product prototypes: (a) Architectural scale prototype
of the interior of a building; (b) Full-size car prototype.
Though the resulting object is perfectly fitted for concept proofing, machining
techniques and materials are not suitable for mass production, once the
product is approved.
Despite their minimized cost, prototypes are often wasteful and nonreusable
(apart in some discipline, like software engineering, where prototypes
can later be integrated in an operational product). This makes the
manufacturing process redundant: one manufacturing process at least for
prototyping, and then another one for real production. It would be highly
advantageous if some test could be directly run against the models themselves,
without the need to create a prototype.
1.4 Product digital mock-up
Sections 1.2 and 1.3 introduced product model and product prototype as
historically two separate concepts. However, technological advances in information
systems allowed engineers to merge those concepts into a single
one, introducing little by little what become known as DMU in the domain
of mechanical engineering.
1.4.1 Computerized product models
With the introduction of information technology and its applications, engineering
and production disciplines tended to make the most out of its
possibilities. One obvious application was modeling. Engineers and designers
soon got convinced to use computers instead of drafting tables to
materialize their designs. This gave way to CAD systems, who were based
on advances in computer-based geometric modelers. Geometric modelers
were first two-dimensional, and offered little advantage over classical draft-8 Chapter 1. DMU and Polymorphic Representation
Figure 1.4: Hand navigator prototype (Chardonnet & L´eon [49]).
ing, apart their ease of use. Soon, those modelers started to address 3D
solid modeling and integrated complementary facilities such as parametric
and feature-based modeling [121, 54]. Digital product models became easier
to produce and to interpret.
With the advent of Model-Based Definition (MDB) paradigm [140, 43],
these models were soon imported into the downstream manufacturing process,
to allow what is now called CAM. CAD models contained information
not only understandable by expert engineers, but also by machine tools to
automatically configure some of the product manufacturing processes.
An important revolution in the field of CAD was the introduction of
3D modelers, thanks to the fast-paced advancement of computer graphics.
This gave the designers a better perception of their work, even tough
incomplete. 3D models now allow engineers to perform basic prototyping,
at least from an aesthetic point of view, with categories of shapes
as prescribed by CAD systems. Indeed, each CAD software enables the
generation of component/product shape within a range prescribed by its
algorithms. As a result, CAD software can detect some geometric inconsistencies,
e.g., self-intersections, invalid topologies, interferences, etc. (see
Section 1.5.1 for a discussion on geometric validity of a DMU), when a component
shape/product falls outside the range of shapes it can describe.
Product models have become more than mere patterns that used to
dictate how the product should be manufactured. The line that separated
models from prototypes got thinner as more and more product assessments
can be readily conducted on the models themselves.
1.4.2 Digital mock-ups as models and prototypes at a time
Computerized product models that also played the role of prototypes are
commonly called digital mock-ups (DMUs). They mainly contain the 3DGeometric models and modeling methods 9
geometric model of a product, but are not restricted to that. As product
models they also incorporate supplementary information about material and
other technological parameters.
The goal of DMUs is not limited to manufacturing only. Now that they
provide detailed geometry alongside material physical properties, different
physical simulations can be set up, taking advantage of increasing computational
capabilities rather than generating physical prototypes.
DMUs can be seen as the result of advances in geometric modeling software
and CAx systems. They directly support manufacturing processes,
fulfilling the role of product referential models, as well as the basis of simulation
mock-ups, and serving as product digital prototypes [177]. By the
late 90s, a DMU was seen as a realistic computer simulation of a product,
with the capability of all required functions from design engineering, manufacturing,
product service, up to maintenance and product recycling [57].
From this perspective, the DMU stems from the merge of product model
and product prototype.
Product geometry is a key information around which the DMU is organized.
Figure 1.5 shows an example of a DMU of a centrifugal pump as
visualized by its 3D representation. Other types of information, essential
for manufacturing and prototyping purposes are also present in the DMU,
and will be discussed in more details in Section 1.7.
In this sense, the DMU works as a repository of the engineering knowledge
about a product that can be used throughout its life cycle [47]. Thus,
DMUs are seen as the backbone of the product development process in todays
industries [64].
Figure 1.6 shows how the generation of technical drawings can be partly
automated using the 3D geometry of CAD models out of the product DMU.
Then, GD&T can be carried out by engineers to add technological data.
1.5 Geometric models and modeling methods
Often CAD systems consider a DMU as a set of components, that may also
be called parts, assembled together to directly form the 3D representation of
a product, or to form modules (sub-assemblies) that in turn are assembled
into a product. Section 1.6 explains different methods and viewpoints about
component assembly. In this section we are more concerned about how a
component is represented geometrically in a CAD system.
Geometric modelers are as important to CAD systems as the product
geometric model is to DMUs. The geometric modeling process is highly
influenced by the category of geometric model attached to a CAD system.
Often, engineers choose to represent a component as a volume; a threedimensional
manifold [131] that divides the 3D-euclidean space into three
sets: its interior C, its boundary ∂C, and its exterior ∼C. Then, the ge-10 Chapter 1. DMU and Polymorphic Representation
Figure 1.5: A DMU geometry of a centrifugal pump, showing different parts
using colors. For a better understanding of the product shape, the DMU is
sectioned by a vertical plane.
ometric model commonly used in CAD systems is of type boundary representation
(B-Rep) [119, 120]. In this case, the material of a component is
described by the topological closure of its interior cl(C), which is the union
of its interior and its boundary cl(C) = C ∪ ∂C [120].
1.5.1 Geometric validity and the quality of a DMU
As digital geometric representations of a product, a DMU may contain unrealistic,
or unrealizable, configurations. An example configuration that is
frequently encountered in industrial models is the volumetric interference
between two solids. This configuration can lead to several interpretations:
a. It might be a by-product of an imprecise design and it is therefore
incorrect;
b. It might also be a deliberate artifact to reflect some conventional meaning
and, in this case, it has no impact on the correctness of the DMU.Geometric models and modeling methods 11
Figure 1.6: Automated generation of a technical drawing from a DMU that
contribute to the definition of a product model.12 Chapter 1. DMU and Polymorphic Representation
P
Δ
H
D
d
Figure 1.7: A cross section of a threaded link between a screw and a nut
represented as a simple interference in a 3D assembly geometric model. The
sub-figure at the right shows how the cross section may look like in a real
product, and the technological parameters it may have conveyed. H: height
of the thread; P: pitch; d: minor diameter of external thread; D: minor
diameter of internal thread; Δ: nominal diameter.
Figure 1.7 shows an example where a threaded link is represented as
such an interference, which falls into the interpretation of type b.
Furthermore, some geometric modelers let a user create non-manifold
configurations (see Figure 1.8). Some of these configurations are useful to
produce a simplified representation of the real object that is needed to perform
mechanical simulations using the finite element method (see Figure 1.8c
and Section 1.9). Those configurations, however, are not physically realizable
[131].
These unrealistic or unrealizable geometric arrangements put a question
about the quality of DMU. One may ask what geometry to consider as
valid, and what to reject or disallow, knowing that these configurations
cannot be filtered out by the algorithms of a geometric modeler. In fact, the
answer to this question highly depends on conventions being followed by the
users or engineers because there is no representation standard that is used
in geometric modelers to discard such arrangements. However, studying
industrial models showed that there exists a general consensus in the domain
of mechanical engineering that geometric degeneracies such as non-manifold
configurations (see Figure 1.8a, b) should be avoided in a DMU, as they
are often misleading as for how to be interpreted. Meanwhile, volumetric
intersections are largely accepted, as they convey a particular meaning.Geometric models and modeling methods 13
(a) (b) (c)
Figure 1.8: (a), (b), (c) Geometric models with highlighted non-manifold
configurations. (c) is an example of simplified representation that can be
used for a mechanical simulation.
Manifold or not, digital geometric models defining products in CAD
systems have their boundary represented either with faceted models, i.e.,
piecewise linear surfaces, or with piecewise smooth surfaces. Here, the first
category is named discrete geometric models and the second one analytic
geometric models.
1.5.2 Discrete geometric models
Discrete geometric models consist of a finite set of geometric elements topologically
connected to each other to define the boundary of a shape. These
elements are manifolds that can be either one, two, or three-dimensional.
The very basic geometric element is a vertex : a point lying in 1D, 2D or
3D-space, this is a zero-dimensional manifold.
Two vertices connect to each other defining a line segment or edge that is
a one-dimensional manifold. An aggregation of edges on the same plane can
form a piecewise 1D curve. If every vertex of this aggregation is topologically
connected with at most two edges per connection1 the curve is indeed a onedimensional
manifold.
A 1D closed2 manifold and planar curve with no self-intersection bounds
a discrete planar area or face, i.e., a two-dimensional geometric entity. An
aggregation of faces connected to each other forms a faceted 2D surface.
If every edge of this aggregation is topologically connected with at most
two faces, while the connection between faces happens uniquely at their
boundary, i.e., at the edge level, the surface is a two-dimensional manifold.
A 2D closed3 manifold surface with no self-intersections bounds a solid,
1Connection between edges happens at their boundary, i.e., either of their vertices.
2A closed curve is a curve in which a connection happens at every vertex of each of its
edges. 3A closed surface is a surface in which a connection happens at every edge of each of14 Chapter 1. DMU and Polymorphic Representation
(a) (b)
Figure 1.9: Geometric models of a teapot: (a) discrete model (triangular
surface mesh); (b) analytical model (composite free-form shape obtained
from a set of surface patches).
a three-dimensional geometric entity. Solids can be aggregated to form more
complex ones. If this aggregation is topologically connected in a way that
connection happens uniquely at face level the resulting solid is manifold.
Discretized models are also called meshes. Meshed objects used in a
product development process to describe a solid are represented in one of
two ways:
Surface meshes
Using a discrete closed surface to define the boundary between the interior
and the exterior of the object. Those surfaces are decomposed of
faces, as mention before. Faces can have an arbitrary number of edges
each, however, surfaces are usually built out of triangles and/or quadrangles.
These models are also called polygon meshes. Figures 1.9a
and 1.10b show examples of surface triangular meshes;
Volume meshes
Using a set of connected simple volumes, such as tetrahedrons and/or
hexahedrons. These models are also called polyhedron meshes. Figure
1.10c shows a cut in a tetrahedral volumetric mesh of a fan blade
foot.
Discrete geometric representations are simple. However, they are not
suitable for the up-stream design phases of a PDP for the following reasons:
• They are approximate representations that imprecisely capture the
designed concept, as they fail to accurately define smooth curves and
surfaces that are mandatory for manufacturing processes. Powerful
shape modeling algorithms are not available in CAD systems;
its faces.Geometric models and modeling methods 15
(a) (b)
(c) (d)
Figure 1.10: Geometric models of a mechanical part (foot of a fan blade):
(a) complete B-Rep analytical model; (b) complete discrete model; (c) a cut
into a volume mesh showing tetrahedrons internal to (b); (d) a section into
a surface mesh showing that the triangles lie on the surface of the solid.16 Chapter 1. DMU and Polymorphic Representation
• Meshes are scale dependent, their level of details, i.e., their roughness,
cannot be adjusted to obtain smoother shapes once the model is
generated;
• The roughness of these models hinder their utilization for machining
purposes in the down-stream process, where smooth realizable surfaces
are expected unless the roughness is lower than that of the manufactured
surface. This constraint however requires a too large amount of
storage to be used for complex products.
As a result, geometric modelers of CAD systems are rarely discrete,
although discrete models can be generated from analytical ones for applications
in finite element simulations (see Section 1.9) and prototyping.
1.5.3 Analytical geometric models
Analytical geometric models use the same concepts defined for discrete models
to describe the topology of their B-Rep model, i.e., vertices, edges, and
faces. However, this topological representation is associated with geometric
models such that edges need not be linear, and faces need not be planar anymore
in these models. This allows for a concise yet precise representation
of smooth piecewise curves and piecewise surfaces.
While vertices still represent points in the euclidean space, an edge is only
partially defined by its two endpoints, since it is also characterized by the
curve on which it lies. To this end, curves are represented mathematically,
either as canonical geometric shapes such as lines and conic sections, or as
parametric equations such as B-splines and B´ezier curves.
The same principle applies to faces which are characterized by the surface
they lie upon, beyond their boundary edges. Carrier surfaces are also represented
mathematically, either as canonical surfaces such as planes, spheres,
cylinders, cones, or tori, or as parametric equations such as B-spline or
B´ezier surfaces, or as implicit surfaces.
Just as in discrete models, two vertices connect to each other forming
and edge, a set of edges forms a composite curve, either manifold or not4.
A closed manifold composite curve defines the boundary of a face, faces
are aggregated to form composite surfaces, again they may or may not be
manifold5. A closed, orientable surface, without self-intersection, defines a
solid C while forming its boundary ∂C.
Analytical geometric models are faithful to the original geometry that
a designer had in mind since they are accurate representations of a real
object. They are scalable with no information loss. Those properties make
4Manifold composite curves connect at most two edges at each of their vertices.
5Manifold surfaces connect at most two faces at each of their edges, while edges are
free to be decomposed into smaller onesGeometric models and modeling methods 17
Figure 1.11: An example of CSG tree where leaves are geometric primitives,
∪ is a Boolean union, ∩ is a Boolean intersection, and − is a Boolean
difference.
it easier for geometric models to provide a reference for processes located
down-stream with respect to the design process.
CAD systems use analytical representations of objects because of these
favorable properties [120, 121]. Geometric modelers represent analytical
models in one of following ways:
Generative methods
Where the object is defined by the process of its generation. One such
method is the Constructive Solid Geometry (CSG) whereby an object
is represented as a tree whose root is the geometric object and its
leaves are elementary geometric entities, i.e., primitives or geometric
objects generated by other generative methods, and internal nodes
are geometric Boolean operations, i.e., union, intersection, differences.
Other generative methods are sweeping, rotation, extrusion, etc. that
generate 3D entities out of 2D sketches.
These models are useful to describe products because they keep track
of a history of their modeling process and because they allow easy
modifications. Geometry modifications are frequent during a product
development process, hence the long lasting interest of CAD systems
in history trees. Figure 1.11 shows an example of a CSG tree and
corresponding geometric shape at each step of its construction;
Descriptive methods
Such as B-Rep, where the object is defined by its boundary. This
object is represented by a set of closed, oriented, non-intersecting surfaces
that forms a multiply connected volume. These models keep no
track of the construction process, however their data size is smaller18 Chapter 1. DMU and Polymorphic Representation
than that of their CSG counterparts. Figure 1.9 shows an example of
a teapot analytically represented by its boundary.
However, B-Rep models are automatically generated from any CSG
description: each CSG boolean operation has a corresponding B-Rep
transformation that represent the results in the B-Rep description.
Actually, hybrid model are used in the DMU context where different BRep
representations are linked: the CAD B-Rep model is the “exact”
representation of the geometry, the visualization model is a 3D mesh
approximating surfaces of the CAD model for user interaction, while
FE mesh model is used for physical simulation using the finite element
method.
Parametric and feature based methods
Which add geometric parameters and shape feature semantics to the
primitives of CSG representation and its resulting B-Rep model. Featurebased
and parametric model represents the history of the geometry
construction process with a tree where leaves are parameterized shape
features having shape characteristics and often functionnal and/or
manufacturing significance: holes, chamfers, pads, pockets, blends,
fastners, etc. Features associate properties and parameters to a set of
topological and geometrical entities of the B-Rep model and a CSG
primitive shape:
• geometric properties (dimensions such as hole diameter, 2D sketches,
extrusion direction, revolution axis, sweeping curve, blending radii,
etc.);
• application-specific properties (machining tool parameters, toolpath,
weld beads, threading parameters, glued surfaces).
Many feature descriptions have a significance for several applications,
e.g. a revolution cut can have a functional meaning (location of a bolted
joint), a manufacturing meaning (drilling process), an assembly process
meaning (fastening process), or a simulation meaning (definition
of boundary conditions for FEA simulation). Unfortunately, manufacturing
and functionnal properties are often missing in feature-based
models due to various reasons: the time required to describe functions
with features is often too long, manufacturing and functional features
available in STEP ISO-10303 standard [7] and implemented in commercial
software cover a small part of configurations.
1.6 DMU as an assembly
Products functionalities are satisfied by mechanical components that are
assembled together to function consistently with respect to each others.DMU as an assembly 19
Vehicle
Structure Powertrain
Body Chassis Gearbox Engine Gearbox Engine
Chassis Powertrain
Frame Body
(a) (b)
Figure 1.12: Two possible simple structures of a car DMU: (a) Assembly
tree organized as per function; (b) Assembly tree organized as per order of
mounting.
The DMU reflects this grouping by representing the product as an assembly
of parts, each representing one mechanical components. This grouping can
occur at different levels to form a tree structure, as components are gathered
in sub-assemblies.
1.6.1 DMU structure
This multi-level organization gives the assembly a tree-like structure for
which the root is the product, nodes are sub-assemblies, and leaves are components.
We note that if generative methods are used to model components,
the latter are also represented in a tree-like structure in CAD systems, with
leaves being the geometric primitives and nodes geometric construction operations
(see Section 1.5.3).
The hierarchical organization of a DMU using an assembly tree structure
is not intrinsic to a product, rather it depends on the criterion used to set up
the tree structure, e.g., functional, organizational, or assemblability. This
criterion is user-defined and the tree structure is defined interactively by the
user.
Functional criteria Components may be grouped according to their functional
contribution to the product. In this case, sub-assemblies represent
functional modules. For example, a car assembly may consist in
a structure and a powertrain. While powertrain can be decomposed
into engine, gearbox, driveshaft, differential, and suspension (see Figure
1.12a). Each of these denominations represent a functional grouping,
a unit that satisfies a specific function of a car6. Functional modules
in their turn consist of components interacting to fulfill a function.
6The decomposition is simplified from what a real car assembly is.20 Chapter 1. DMU and Polymorphic Representation
Figure 1.13 shows a snapshot of a commercial CAD software (CATIA
V5) showing the tree structure of the DMU of a centrifugal
pump shown on Figure 1.5. Sub-assemblies are organized according to
their functional properties. The tree expands the casing sub-assembly
(Carter pompe centrifuge) having as function to contain the fluid,
inside which it expands the volute housing part (Volute pompe) having
as function to drive the centrifugal movement of the fluid.
Organizational criteria Sub-assemblies arrangement may also reflect criteria
based on manufacturing and organizational choices rather than
internal functional coherence. For instance, if different components of
the product are designed and/or manufactured in different companies,
those parts are likely to be separated in a sub-assembly, even though
they do not constitute a valid functional unit on their own. Figure 1.14
shows how the aircraft structure of an Airbus A380 is divided into subassemblies,
each being manufactured at a different facility, possibly in
a different country.
Assemblability criteria Another aspect that can be encoded in a digital
assembly structure is the mounting sequence of components alongside
the assembly line. In this case, a sub-assembly represents a set of
elements, i.e., components or other sub-assemblies, that are put up
together at once. The depth of hierarchy represents the order in which
installation occurs. For instance, while chassis and powertrain are
two different sub-assemblies of a car, powertrain itself is decomposed
into engine and gearbox whose components are mounted separately,
and at an earlier stage than the assembly of the powerengine (see
Figure 1.12b).
It is worth mentioning that no matter what criterion is used to organize
a DMU structure, this knowledge is still partial and unreliable. This is
not only the subsequence of lack of norms and standards, but also because
the strict tree-like structure is incapable of representing certain semantic
groupings such as functional clusters where overlapping sets may occur,
or kinematic chains where cyclic graphs are expected rather than a tree
structure.
1.6.2 Components’ positioning
In real configurations, components are positioned relatively to each others
through contacts and other assembly techniques, e.g., clamping and welding.
In a DMU, however, the product is represented as a geometric model, and its
components as digital solids (see Section 1.5). Contacts lose their physical
meaning, welding and gluing are rarely represented, and some other unreal-DMU as an assembly 21
Figure 1.13: A snapshot from a commercial CAD software (CATIA V5)
showing a DMU as its geometric representation (see Figure 1.5), alongside
its tree-structure.22 Chapter 1. DMU and Polymorphic Representation
1
2
3
4
5
6
1
7