Parallel Processing of Public Open Data with the MapReduce Paradigm : A Case Study

Parallel Processing of Public Open Data with the MapReduce Paradigm : A Case Study - Revenir à l'accueil

Parallel Processing of Public Open Data with the MapReduce Paradigm : A Case Study

Billel ARRES 1, * Omar Boussaid 1 Nadia KABACHI 1 Fadila Bentayeb 1 
* Auteur correspondant
1 SID
ERIC - Equipe de Recherche en Ingénierie des Connaissances
Abstract : Nowadays, many governments and states are involved in an opening strategy of their public data. However, the volume of these opened data is constantly increasing, and will reach in the near future limitations of current treatment and storage capacity. On the other hand, the MapReduce paradigm is one of the most used parallel programming models. With a master-slave architecture, it allows parallel processing of very large data sets. In this paper, we propose a parallel approach based on Mapreduce to process public open data. Applied, as a case study, to the official data sets from the French Ministry of Communication. We implement a parallel algorithm as a solution to define a ranking of national museums and galleries according to the accessibility degrees for people with disabilities. We studied the feasibility of our approach in two main parts: The performance in terms of execution time, and, the visualization of the obtained results in order to integrate them into solutions such as geographic BI. This work can be applied to other cases with very large data sets.
keyword : Big data Open Data Mapreduce Big data Open Data Mapreduce
Type de document : 
Communication dans un congrès
Big Spatial Data, Jul 2014, Orléans, France. pp.132-141
Domaine :
Informatique / Calcul parallèle, distribué et partagé
 
Source : 
 
https://hal.archives-ouvertes.fr/hal-01023308

 

Autres documents sur la Big Data :

[TXT]

 Big-DATA-effet-de-mo..> 20-Dec-2014 17:28  8.2M  

[TXT]

 Big-Data-Alchemy-Cap..> 20-Dec-2014 17:57  8.1M  

[TXT]

 Big-Data-Analyse-des..> 20-Dec-2014 17:28  8.2M  

[TXT]

 Big-Data-At-the-Big-..> 22-Dec-2014 07:47  1.5M  

[TXT]

 Big-Data-Big-Data-Fo..> 21-Dec-2014 11:00  1.4M  

[TXT]

 Big-Data-Charte-ethi..> 21-Dec-2014 10:38  4.7M  

[TXT]

 Big-Data-Comportemen..> 21-Dec-2014 10:35  1.4M  

[TXT]

 Big-Data-Comportemen..> 21-Dec-2014 10:38  4.6M  

[TXT]

 Big-Data-Donnees-num..> 22-Dec-2014 07:47  1.5M  

[TXT]

 Big-Data-French-Japa..> 21-Dec-2014 10:35  1.4M  

[TXT]

 Big-Data-Institut-Lo..> 20-Dec-2014 18:00  8.2M  

[TXT]

 Big-Data-Introductio..> 20-Dec-2014 17:53  4.1M  

[TXT]

 Big-Data-L-ecosystem..> 21-Dec-2014 10:36  1.3M  

[TXT]

 Big-Data-La-Chaire-A..> 20-Dec-2014 17:54  4.0M  

[TXT]

 Big-Data-Le-big-data..> 20-Dec-2014 18:09  4.5M  

[TXT]

 Big-Data-Le-defi-MAS..> 22-Dec-2014 06:57  1.5M  

[TXT]

 Big-Data-Les-cahiers..> 20-Dec-2014 18:00  8.3M  

[TXT]

 Big-Data-MASTODONS-U..> 21-Dec-2014 10:37  2.3M  

[TXT]

 Big-Data-Marketing-P..> 22-Dec-2014 07:46  1.5M  

[TXT]

 Big-Data-Mastere-Spe..> 20-Dec-2014 17:29  8.1M  

[TXT]

 Big-Data-Synthese-du..> 20-Dec-2014 17:53  4.1M  

[TXT]

 Big-Data-TACKLING-TH..> 20-Dec-2014 17:54  4.0M  

[TXT]

 Big-Data-Telecharger..> 20-Dec-2014 18:09  4.4M  

[TXT]

 Big-Data-Un-etat-des..> 20-Dec-2014 18:07  4.5M  

[TXT]

 Big-Data-Une-approch..> 21-Dec-2014 10:37  2.3M  

[TXT]

 Big-Data-Une-approch..> 21-Dec-2014 11:00  1.4M  

[TXT]

 Big-Data-et-Graphes-..> 21-Dec-2014 10:36  2.3M  

[TXT]

 Big-Data-la-deferlan..> 22-Dec-2014 07:47  1.5M  

[TXT]

 Big-Data-la-vision-d..> 22-Dec-2014 07:46  1.5M  

[TXT]

 Big-Data-un-Master-c..> 20-Dec-2014 18:07  4.5M  

[TXT]

 White-paper-Big-Data..> 20-Dec-2014 17:57  8.1M 
Scalable data-management systems for Big Data Viet-Trung Tran To cite this version: Viet-Trung Tran. Scalable data-management systems for Big Data. Other. Ecole normale ´ sup´erieure de Cachan - ENS Cachan, 2013. English. . HAL Id: tel-00920432 https://tel.archives-ouvertes.fr/tel-00920432 Submitted on 18 Dec 2013 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.THÈSE / ENS CACHAN - BRETAGNE sous le sceau de l’Université européenne de Bretagne pour obtenir le titre de DOCTEUR DE L’ÉCOLE NORMALE SUPÉRIEURE DE CACHAN Mention : Informatique École doctorale MATISSE présentée par Viet-Trung TRAN Préparée à l’Unité Mixte de Recherche n° 6074 Institut de recherche en informatique et systèmes aléatoires Scalable data management systems for Big Data Thèse soutenue le 21 janvier 2013 devant le jury composé de : Franck CAPPELLO Directeur de recherche, INRIA, Saclay, France / rapporteur Pierre SENS Professeur, Université de Paris 6, France / rapporteur Guillaume PIERRE Professeur, Université de Rennes 1, France / examinateur Patrick VALDURIEZ Directeur de recherche, INRIA Sophia, France / examinateur Dushyanth NARAYANAN Chercheur, Microsoft Research Cambridge, Angleterre / examinateur invité Luc BOUGÉ Professeur, ENS Cachan Antenne de Bretagne, France / directeur de thèse Gabriel ANTONIU Directeur de recherche, INRIA, Rennes, France / directeur de thèseENSC - 2013 n°..... École normale supérieure de Cachan - Antenne de Bretagne Campus de Ker Lann - Avenue Robert Schuman - 35170 BRUZ Tél : +33(0)2 99 05 93 00 - Fax : +33(0)2 99 05 93 29 Résumé La problématique «Big Data» peut être caractérisée par trois «V»: - «Big Volume» se rapporte à l’augmentation sans précédent du volume des données. - «Big Velocity» se réfère à la croissance de la vitesse à laquelle ces données sont déplacées entre les systèmes qui les gèrent. - «Big Variety» correspond à la diversi%cation des formats de ces données. Ces caractéristiques imposent des changements fondamentaux dans l’architecture des systèmes de gestion de données. Les systèmes de stockage doivent être adaptés à la croissance des données, et se doivent de passer à l’échelle tout en maintenant un accès à hautes performances. Cette thèse se concentre sur la construction des systèmes de gestion de grandes masses de données passant à l’échelle. Les deux premières contributions ont pour objectif de fournir un support e&cace des «Big Volumes» pour les applications data-intensives dans les environnements de calcul à hautes performances (HPC). Nous abordons en particulier les limitations des approches existantes dans leur gestion des opérations d’entrées/sorties (E/S) non-contiguës atomiques à large échelle. Un mécanisme basé sur les versions est alors proposé, et qui peut être utilisé pour l’isolation des E/S non-contiguës sans le fardeau de synchronisations coûteuses. Dans le contexte du traitement parallèle de tableaux multi-dimensionels en HPC, nous présentons Pyramid, un système de stockage large-échelle optimisé pour ce type de données. Pyramid revoit l’organisation physique des données dans les systèmes de stockage distribués en vue d’un passage à l’échelle des performances. Pyramid favorise un partitionnement multi-dimensionel de données correspondant le plus possible aux accès générés par les applications. Il se base également sur une gestion distribuée des métadonnées et un mécanisme de versioning pour la résolution des accès concurrents, ce a%n d’éliminer tout besoin de synchronisation. Notre troisième contribution aborde le problème «Big Volume» à l’échelle d’un environnement géographiquement distribué. Nous considérons BlobSeer, un service distribué de gestion de données orienté «versioning», et nous proposons BlobSeer-WAN, une extension de BlobSeer optimisée pour un tel environnement. BlobSeer-WAN prend en compte la hiérarchie de latence et favorise les accès aux méta-données locales. BlobSeer-WAN inclut la réplication asynchrone des méta-données et une résolution des collisions basée sur des «vector-clock». A%n de traîter le caractère «Big Velocity» de la problématique «Big Data», notre dernière contribution consiste en DStore, un système de stockage en mémoire orienté «documents» qui passe à l’échelle verticalement en exploitant les capacités mémoires des machines multi-coeurs. Nous montrons l’e&cacité de DStore dans le cadre du traitement de requêtes d’écritures atomiques complexes tout en maintenant un haut débit d’accès en lecture. DStore suit un modèle d’exécution mono-thread qui met à jour les transactions séquentiellement, tout en se basant sur une gestion de la concurrence basée sur le versioning a%n de permettre un grand nombre d’accès simultanés en lecture. Abstract Big Data can be characterized by 3 V’s. • Big Volume refers to the unprecedented growth in the amount of data. • Big Velocity refers to the growth in the speed of moving data in and out management systems. • Big Variety refers to the growth in the number of di8erent data formats. Managing Big Data requires fundamental changes in the architecture of data management systems. Data storage should continue being innovated in order to adapt to the growth of data. They need to be scalable while maintaining high performance regarding data accesses. This thesis focuses on building scalable data management systems for Big Data. Our %rst and second contributions address the challenge of providing e&cient support for Big Volume of data in data-intensive high performance computing (HPC) environments. Particularly, we address the shortcoming of existing approaches to handle atomic, non-contiguous I/O operations in a scalable fashion. We propose and implement a versioning-based mechanism that can be leveraged to o8er isolation for non-contiguous I/O without the need to perform expensive synchronizations. In the context of parallel array processing in HPC, we introduce Pyramid, a large-scale, array-oriented storage system. It revisits the physical organization of data in distributed storage systems for scalable performance. Pyramid favors multidimensional-aware data chunking, that closely matches the access patterns generated by applications. Pyramid also favors a distributed metadata management and a versioning concurrency control to eliminate synchronizations in concurrency. Our third contribution addresses Big Volume at the scale of the geographically distributed environments. We consider BlobSeer, a distributed versioning-oriented data management service, and we propose BlobSeer-WAN, an extension of BlobSeer optimized for such geographically distributed environments. BlobSeerWAN takes into account the latency hierarchy by favoring locally metadata accesses. BlobSeer-WAN features asynchronous metadata replication and a vector-clock implementation for collision resolution. To cope with the Big Velocity characteristic of Big Data, our last contribution feautures DStore, an in-memory document-oriented store that scale vertically by leveraging large memory capability in multicore machines. DStore demonstrates fast and atomic complex transaction processing in data writing, while maintaining high throughput read access. DStore follows a single-threaded execution model to execute update transactions sequentially, while relying on a versioning concurrency control to enable a large number of simultaneous readers.The completion of this thesis was made possible through the patience and guidance of my advisor, Gabriel and Luc. I am grateful for their support and encouragements not only in my research life but also in my social life. Thank you Luc and Gabriel for giving me the opportunity to pursue a Master and then a PhD in their research team. I wish to express my gratitude to my thesis reviewers, Franck CAPPELLO, Pierre SENS and the members of my dissertation committee, Guillaume PIERRE, Patrick VALDURIEZ and Dushyanth NARAYANAN. Thank you for accepting to review my manuscript and for the insightful comments and careful consideration given to each aspect of my thesis. In particular, many thanks to Dushyanth for hosting me at Microsoft Research Cambridge, UK as an intern within the Systems and Networking Group for a duration of three months. During this stay, I had the change to broaden my knowledge in the area of in-memory Big Data analytics. I have been fortunate enough to have the help and support of many friends and colleagues, especially all of my colleagues in KerData team. I will not be listing all of their particular support in here as my manuscript will end up in thousands pages. Thanks Bogdan Nicolae for co-working with me for the first two contributions of this thesis. Thanks Matthieu Dorier for translating the abstract of this manuscript into French. Thanks Alexandru Farcasanu for working with me in running some experiments of DStore in the Grid’5000 testbed. I cannot find words to express my gratitude for the girls, Alexandra, Diana and Izabela. Those are the angels of mine in France and I wish they will alway be forever. My deepest thanks go to my family, for their unconditional support and encouragements even from a very far distance. Finally, many thanks to all other people that had a direct or indirect contribution to this work and were not explicitly mentioned above. Your help and support is very much appreciated.2 To myself Embrace the dreams and be happy in life.i Contents 1 Introduction 1 1.1 Context: Big Data management . . . . . . . . . . . . . . . . . . . . . . . . . . . 1 1.2 Contributions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 1.3 Publications . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 1.4 Organization of the manuscript . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 Part I – Context: Scalability in Big Data management systems 7 2 Current infrastructures for Big Data management 9 2.1 Definition of Big Data . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10 2.2 Clusters . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 2.2.1 Commodity clusters . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 2.2.2 High performance computing (HPC) clusters (a.k.a. Supercomputers . 12 2.3 Grids . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12 2.3.1 Grid architecture . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13 2.3.2 Grid middleware . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14 2.4 Clouds . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 2.4.1 Cloud Architecture . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16 2.4.2 Cloud middleware . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17 2.5 Big memory, multi-core servers . . . . . . . . . . . . . . . . . . . . . . . . . . . 18 2.6 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19 3 Designing data-management systems: Dealing with scalability 21 3.1 Scaling in cluster environments . . . . . . . . . . . . . . . . . . . . . . . . . . . 22 3.1.1 Centralized file servers . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22 3.1.2 Parallel file systems . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23 3.1.3 NoSQL data stores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25 3.2 Scaling in geographically distributed environments . . . . . . . . . . . . . . . 26 3.2.1 Data Grids . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26 3.2.2 Scalability concerns and trade-offs . . . . . . . . . . . . . . . . . . . . . 27 3.2.3 Examples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28 3.3 Scaling in big-memory, multi-core servers . . . . . . . . . . . . . . . . . . . . . 28 3.3.1 Current trends in scalable architectures . . . . . . . . . . . . . . . . . . 28ii Contents 3.3.2 Examples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29 3.4 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30 4 Case study - BlobSeer: a versioning-based data storage service 31 4.1 Design overview . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31 4.2 Architecture . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33 4.3 Versioning-based access interface . . . . . . . . . . . . . . . . . . . . . . . . . . 34 4.4 Distributed metadata management . . . . . . . . . . . . . . . . . . . . . . . . . 35 4.5 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36 Part II – Scalable distributed storage systems for data-intensive HPC 37 5 Data-intensive HPC 39 5.1 Parallel I/O . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40 5.1.1 Parallel I/O stack . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40 5.1.2 Zoom on MPI-I/O optimizations . . . . . . . . . . . . . . . . . . . . . . 41 5.2 Storage challenges in data-intensive HPC . . . . . . . . . . . . . . . . . . . . . 42 5.3 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44 6 Providing efficient support for MPI-I/O atomicity based on versioning 45 6.1 Problem description . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46 6.2 System architecture . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47 6.2.1 Design principles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47 6.2.2 A non-contiguous, versioning-oriented access interface . . . . . . . . . 49 6.3 Implementation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50 6.3.1 Adding support for MPI-atomicity . . . . . . . . . . . . . . . . . . . . . 51 6.3.2 Leveraging the versioning-oriented interface at the level of the MPII/O layer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 54 6.4 Evaluation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 54 6.4.1 Platform description . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55 6.4.2 Increasing number of non-contiguous regions . . . . . . . . . . . . . . 56 6.4.3 Scalability under concurrency: our approach vs. locking-based . . . . 56 6.4.4 MPI-tile-IO benchmark results . . . . . . . . . . . . . . . . . . . . . . . 58 6.5 Positioning of the contribution with respect to related work . . . . . . . . . . 60 6.6 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61 7 Pyramid: a large-scale array-oriented storage system 63 7.1 Motivation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64 7.2 Related work . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65 7.3 General design . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 66 7.3.1 Design principles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 66 7.3.2 System architecture . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68 7.4 Implementation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69 7.4.1 Versioning array-oriented access interface . . . . . . . . . . . . . . . . . 69 7.4.2 Zoom on chunk indexing . . . . . . . . . . . . . . . . . . . . . . . . . . 70 7.4.3 Consistency semantics . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71Contents iii 7.5 Evaluation on Grid5000 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71 7.6 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 74 Part III – Scalable geographically distributed storage systems 75 8 Adapting BlobSeer to WAN scale: a case for a distributed metadata system 77 8.1 Motivation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78 8.2 State of the art: HGMDS and BlobSeer . . . . . . . . . . . . . . . . . . . . . . . 79 8.2.1 HGMDS: a distributed metadata-management system for global file systems . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79 8.2.2 BlobSeer in WAN context . . . . . . . . . . . . . . . . . . . . . . . . . . 80 8.3 BlobSeer-WAN architecture . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80 8.4 Implementation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 82 8.4.1 Optimistic metadata replication . . . . . . . . . . . . . . . . . . . . . . 82 8.4.2 Multiple version managers using vector clocks . . . . . . . . . . . . . . 83 8.5 Evaluation on Grid’5000 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85 8.6 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86 Part IV – Vertical scaling in document-oriented stores 89 9 DStore: An in-memory document-oriented store 91 9.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92 9.2 Goals . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 93 9.3 System architecture . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 94 9.3.1 Design principles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 94 9.3.2 A document-oriented data model . . . . . . . . . . . . . . . . . . . . . 97 9.3.3 Service model . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 98 9.4 DStore detailed design . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99 9.4.1 Shadowing B+tree . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99 9.4.2 Bulk merging . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 101 9.4.3 Query processing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 101 9.5 Evaluation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 103 9.6 Related work . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109 9.7 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110 Part V – Conclusions: Achievements and perspectives 111 10 Conclusions 113 10.1 Achievements . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 114 10.1.1 Scalable distributed storage systems for data-intensive HPC . . . . . . 114 10.1.2 Scalable geographically distributed storage systems . . . . . . . . . . . 115 10.1.3 Scalable storage systems in big memory, multi-core machines . . . . . 115iv Contents 10.2 Perspectives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 116 10.2.1 Exposing versioning at the level of MPI-I/O . . . . . . . . . . . . . . . 116 10.2.2 Pyramid with active storage support . . . . . . . . . . . . . . . . . . . . 117 10.2.3 Leveraging Pyramid as a storage backend for higher-level systems . . 117 10.2.4 Using BlobSeer-WAN to build a global distributed file system . . . . . 117 10.2.5 Evaluating DStore with real-life applications and standard benchmarks 118 Part VI – Appendix 127 11 Résumé en Francais 129 11.1 Context: Big Data management . . . . . . . . . . . . . . . . . . . . . . . . . . . 129 11.2 Contributions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 131 11.2.1 Systèmes de stockage scalable, distribués pour la calcul haute performance (HPC) de données à forte intensité . . . . . . . . . . . . . . . . . 131 11.2.2 Systèmes de stockage scalables, distribués géographiquement . . . . . 132 11.2.3 Systèmes de stockage scalables pour les machines multi-cœur avec de grande mémoire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 133 11.3 Publications . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 133 11.4 Organisation du manuscrit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1341 Chapter 1 Introduction Contents 1.1 Context: Big Data management . . . . . . . . . . . . . . . . . . . . . . . . . 1 1.2 Contributions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 1.3 Publications . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 1.4 Organization of the manuscript . . . . . . . . . . . . . . . . . . . . . . . . . 4 1.1 Context: Big Data management T O date, more and more data are captured or generated than ever before. According to the 2011 Digital Universe study of International Data Corporation (IDC), the amount of information the world produced in 2011 surpassed 1.8 Zettabytes (ZB), which marks an exponential growth by a factor of nine in just five years. Interestingly, the study also shows that the amount of data generated by individual users such as documents, photos, digital musics, blogs is far less than the amount of data being produced by applications and Internet services about their activities on their generated data (documents, photos, blogs, etc.). The sources of this data explosion can be easily identified. Nowadays, the world has approximately 5 billion mobile phones, millions of sensors to capture almost every aspect of life. As a particular example on Facebook, over 900 million active users share about 30 billion pieces of contents per month. Within the same time interval, over 20 billion Internet searches are performed. In the world of Data-intensive High Performance Computing (HPC), Large Hadron Collider (LHC) Grid[1] produces roughly 25 PB of data at the I/O rate of 300 GB/s annually. In this context, “Big Data” is becoming a hot term used to characterize the recent explosion of data. Everybody ranging from Information technology companies to business2 Chapter 1 – Introduction firms is buzzing about “Big Data”. Indeed, Big Data describes the unprecedented growth of data generated and collected from all kinds of data sources that we mentioned above. This growth can be in the volume of data or in the speed of data moving in and out datamanagement systems. It can also be the growth in the number of different data formats in terms of structured or unstructured data. Addressing the need of Big Data management highly requires fundamental changes in the architecture of data-management systems. Data storage should continue innovating in order to adapt to the growth of Big Data. They need to be scalable while maintaining high performance for data accesses. Thus, this thesis focuses on building scalable data management systems for Big Data. 1.2 Contributions The main contributions of this thesis can be summarized as follows. Building a scalable storage system to provide efficient support for MPI-I/O atomicity The state of the art shows that current storage systems do not support atomic, noncontiguous I/O operations in a scalable fashion. ROMIO, the MPI-I/O implementation, is forced to implement this lacking feature through locking-based mechanisms: write operations simply lock the smallest contiguous regions of the file that cover all non-contiguous regions that need to be written. Under a high degree of concurrency, such an approach is not scalable and becomes a major source of bottleneck. We address this shortcoming of existing approaches by proposing to support atomic, non-contiguous I/O operations explicitly at the level of storage back-ends. We introduce a versioning-based mechanism that offers isolation for non-contiguous I/O operations and avoids the need to perform expensive synchronization. A prototype was built along this idea and was integrated with ROMIO in order to enable applications to use our prototype transparently without any modification. We conduct a series of experiments on Grid’5000 testbed and show that our prototype can support non-contiguous I/O operations in a scalable fashion. Pyramid: a large-scale array-oriented storage system In the context of data-intensive High Performance Computing (HPC), a large class of applications focuses on parallel array processing: small different subdomains of huge multidimensional arrays are concurrently accessed by a large number of clients, both for reading and writing. Because multi-dimensional data get serialized into a flat sequence of bytes at the level of the underlying storage system, a subdomain (despite seen by the application processes as a single chunk of memory) maps to a series of complex non-contiguous regions in the file, all of which have to be read/written at once by the same process. We propose to avoid such an expensive mapping that destroys the data locality by redesigning the way data is stored in distributed storage systems, so that it closely matches the access pattern generated by applications. We design and implement Pyramid, a large-scale arrayoriented storage system that leverages an array-oriented data model and a versioning-based1.3 – Publications 3 concurrency control to support parallel array processing efficiently. Experimental evaluation demonstrates substantial scalability improvements brought by Pyramid with respect to state-of-art approaches, both in weak and strong scaling scenarios, with gains of 100 % to 150 %. Towards a globally distributed file systems: adapting BlobSeer to WAN scale To build a globally scalable distributed file system that spreads over a wide area network (WAN), we propose an integrated architecture for a storage system relying on a distributed metadata-management system and BlobSeer, a large-scale data-management service. Since BlobSeer was initially designed to run on cluster environments, it is necessary to extend BlobSeer in order to take into account the latency hierarchy on multi-geographically distributed environments. We propose an asynchronous metadata replication scheme to avoid high latency in accessing metadata over WAN interconnections. We extend the original BlobSeer with an implementation of multiple version managers and leverages vector clocks for detection and resolution of collision. Our prototype, denoted BlobSeer-WAN is evaluated on the Grid’5000 testbed and shows promising results. DStore: an in-memory document-oriented store As a result of continuous innovation in hardware technology, computers are made more and more powerful than their prior models. Modern servers nowadays can possess large main memory capability that can size up to 1 Terabytes (TB) and more. As memory accesses are at least 100 times faster than disk, keeping data in main memory becomes an interesting design principle to increase the performance of data management systems. We design DStore, a document-oriented store residing in main memory to fully exploit high-speed memory accesses for high performance. DStore is able to scale up by increasing memory capability and the number of CPU-cores rather than scaling horizontally as in distributed datamanagement systems. This design decision favors DStore in supporting fast and atomic complex transactions, while maintaining high throughput for analytical processing (readonly accesses). This goal is (to our best knowledge) not easy to achieve with high performance in distributed environments. DStore is built with several design principles: single threaded execution model, parallel index generations, delta-indexing and bulk updating, versioning concurrency control and trading freshness for performance of analytical processing. This work was carried out in collaboration with Dushyanth Narayanan at Microsoft Research Cambridge, as well as Gabriel Antoniu and Luc Bougé, INRIA Rennes, France. 1.3 Publications Journal: • Towards scalable array-oriented active storage: the Pyramid approach. Tran V.-T., Nicolae B., Antoniu G. In the ACM SIGOPS Operating Systems Review 46(1):19-25. 2012. (Extended version of the LADIS paper). http://hal.inria.fr/hal-006409004 Chapter 1 – Introduction International conferences and workshops: • Pyramid: A large-scale array-oriented active storage system. Tran V.-T., Nicolae B., Antoniu G., Bougé L. In The 5th Workshop on Large Scale Distributed Systems and Middleware (LADIS 2011), Seattle, September 2011. http://hal.inria.fr/inria-00627665 • Efficient support for MPI-IO atomicity based on versioning. Tran V.-T., Nicolae B., Antoniu G., Bougé L. In Proceedings of the 11th IEEE/ACM International Symposium on Cluster, Cloud, and Grid Computing (CCGrid 2011), 514 - 523, Newport Beach, May 2011. http://ieeexplore.ieee.org/xpl/articleDetails.jsp?arnumber=5948642 • Towards A Grid File System Based on a Large-Scale BLOB Management Service. Tran V.-T., Antoniu G., Nicolae B., Bougé L. In Proceedings of the CoreGRID ERCIM Working Group Workshop on Grids, P2P and Service computing, Delft, August 2009. http:// hal.inria.fr/inria-00425232 Posters: • Towards a Storage Backend Optimized for Atomic MPI-I/O for Parallel Scientific Applications. Tran V.-T. In The 25th IEEE International Parallel and Distributed Processing Symposium (IPDPS 2011): PhD Forum (2011), 2057 - 2060, Anchorage, May 2011. http://hal.inria.fr/inria-00627667 Research reports: • Efficient support for MPI-IO atomicity based on versioning. Tran V.-T., Nicolae B., Antoniu G., Bougé L. INRIA Research Report No. 7787, INRIA, Rennes, France, 2010. http: //hal.inria.fr/inria-00546956 • Un support efficace pour l’atomicité MPI basé sur le versionnage des données Tran V.-T. INRIA Research Report. INRIA, Rennes, France, 2011. http://hal.inria.fr/hal-00690562 1.4 Organization of the manuscript The rest of this thesis is organized in five parts, briefly described in the following. Part I: Scalability in Big Data management systems We discuss the context of our work by presenting the related research areas. This part consists of Chapter 2, 3 and 4. Chapter 2 introduces Big Data and the state of the art of current infrastructures for Big Data management. Particularly, we first focus on distributed infrastructures that are designed to aggregate resources to scale such as clusters, grids, and clouds. Secondly, we introduce a centralized infrastructure that attracted increasingly attention in Big Data processing: a single server with very large memory and multi-core multiprocessor. Chapter 3 narrows the focus on the data-management systems, and the way they are1.4 – Organization of the manuscript 5 designed to achieve scalability. We classify those systems in the catalogs presented in Chapter 2. In Chapter 4, we study BlobSeer in deep, a large-scale data-management service. We use BlobSeer as a reference system throughout this manuscript. Part II: Scalable distributed storage systems for data-intensive HPC This part consists of 3 chapters. In Chapter 5, we present some common I/O practices in data-intensive high performance computing (HPC). We argue that data-intensive HPC is one type of Big Data, and we highlight some challenges of scalable storage systems in such an environment. We continue in Chapter 6 by presenting our first contribution: design and implement a scalable storage system to provide efficient support for MPI-I/O atomicity. Finally, this part ends with Chapter 7, where we introduce our second contribution in the context of data-intensive HPC. This Chapter features the design, the architecture and then the evaluations of Pyramid: a large-scale array-oriented storage system that is optimized for parallel array processing. Part III: Scalable geographically distributed storage systems We present our contribution on building a scalable storage system in geographically distributed environments. Chapter 8 introduces our motivation to take BlobSeer as a building block for a global distributed file system. We discuss how we re-architect BlobSeer to adapt to WAN scale and then focus on the changes in the implementation of the new BlobSeer’s branch: BlobSeer-WAN. The chapter closes with a set of experiments that evaluate the scalable performance of the system in comparison with that of the original BlobSeer. Part IV: Vertical scaling in document-oriented stores In this part, we discuss our fourth contribution on designing a scalable data-management system in centralized environments. Concretely, we present DStore: a document-oriented store that leverages large main memory, multi-core, multiprocessor architecture to scale vertically. We focus on giving out a clear motivation for the design and a clear description of the system architecture. Evaluation of the work is then presented at the end of the chapter. Part V: Achievements and perspectives This part consists of Chapter 10. We summarize the contributions of this thesis, discuss the limitations and a series of perspectives for future explorations.6 Chapter 1 – Introduction7 Part I Context: Scalability in Big Data management systems9 Chapter 2 Current infrastructures for Big Data management Contents 2.1 Definition of Big Data . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10 2.2 Clusters . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 2.2.1 Commodity clusters . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 2.2.2 High performance computing (HPC) clusters (a.k.a. Supercomputers 12 2.3 Grids . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12 2.3.1 Grid architecture . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13 2.3.2 Grid middleware . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14 2.4 Clouds . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 2.4.1 Cloud Architecture . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16 2.4.2 Cloud middleware . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17 2.5 Big memory, multi-core servers . . . . . . . . . . . . . . . . . . . . . . . . . . 18 2.6 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19 I N this Chapter, we aims at presenting readers a clear definition of Big Data and the evolution of current infrastructures with regard to the need of Big Data management. First, we survey distributed infrastructures such as Clusters, Grids, Clouds that aggregate the resources of multiple computers. Second, we focus on centralized infrastructure that refers to a single server with a very large main memory shared by possibly multiple cores and/or processors. This centralized infrastructure is increasingly attractive for Big Data that requires high speed of data processing/analyzing.10 Chapter 2 – Current infrastructures for Big Data management 2.1 Definition of Big Data According to M. Stonebreaker, Big Data can be defined as “the 3V’s of volume, velocity and variety” [2]. Big Data processing refers to applications that have at least one of the following characteristics. BIG VOLUME. Big Data analysis often needs to process Terabytes (TBs) of data, and even more. On a daily basis, Twitter users generate approximately 7 TBs of data, Facebook users share one billion pieces of content that worth 10 TBs, etc. It is obvious that the volume of data is the most immediate challenge for conventional data-management and processing frameworks. This requires a fundamental change in the architecture of scalable storage systems. In fact, many companies have archived a large amount of data in form of logs, but they are not capable to process them due to a lack of appropriate hardware and software frameworks for Big Data. Current state of the art highlights the increasing popularity of parallel processing frameworks (e.g., Hadoop [3] with MapReduce [4] model) as efficient tools for Big Data analytics. MapReduce is inspired by the Map and Reduce functions in functional programing but it is still a novel approach for processing large amounts of information using commodity machines. Thanks to MapReduce, the companies that have big volume of data can now quickly produce meaningful insights from their data to improve their services and to direct their products better. BIG VELOCITY. A conventional meaning of velocity is how quickly data are generated and needs to be handled. To be able to deliver new insights from input data quickly, batch processing such as in MapReduce is no longer the only preferred solution. There is an increasing need to process Big Data “on-line” where data processing speed is needed to be close to that of data flows. One intuitive example is the following: if what we had was only a 10 minutes old snapshot of traffic flows, we would not dare to cross the road because the traffic changes so quickly. This is one of many cases where MapReduce and Hadoop can not fit to the “on-line” speed requirements. We simply cannot wait for a batch job on Hadoop to complete because the input data would change before we get any result from the processing process. BIG VARIETY. Nowadays, data sources are diverse. With the explosion of Internet-capable gadgets, data such as texts, images are collected from anywhere and by nearly any device (e.g., sensors, mobile phones, etc.) in terms of raw data, structured data and semi-structured data. Data has become so complex that specialized storage systems are needed to deal with multiple data formats efficiently. Recent storage trends have shown the raise of NoSQL approaches and particularly the emergence of graph databases for storing data of social networks. Although current relational database management systems (DBMS) can be used as “one-size-fit-all” for just every type of data formats, doing so results in poor performance as proved by a recent study [5]. By implementing the application-needed data models (e.g. Key/ Value, Document-oriented, Graph-oriented), NoSQL storage devices are able to scale horizontally to adapt to the increasing workloads. According to the above characteristics of Big Data, our contributions in this thesis can be classified as in the Table 2.1. We will present our arguments to support this classification2.2 – Clusters 11 Contribution Big Volume Big Velocity Big Variety Building a scalable storage system to provide efficient support for MPI-I/O atomicity √ — — Pyramid: a large-scale array-oriented storage system √ — √ Towards a globally distributed file systems: adapting BlobSeer to WAN scale √ — — DStore: an in-memory document-oriented store — √ √ Table 2.1: Our contributions with regard to Big Data characteristics ( √ = Addressed, — = not addressed). further in the next chapters. 2.2 Clusters Cluster computing is considered as the first effort to build distributed infrastructures by interconnecting individual computers through fast local area networks. The goal is to gain performance and availability compared to the case when a high-end computer with comparable performance or availability is less cost-effective or unfeasible. The simplicity of installation and administration has made clusters become popular and important infrastructures. There is no strict rules to build a cluster. An inexpensive cluster can be built from commodity computers, called nodes, connected through Ethernet networks, while a high-end cluster built on expensive high-end computers with high speed interconnections. The most basic cluster configuration is Beowulf [6, 7], originally referred to a specific cluster built by NASA in 1994. Beowulf cluster consists of normally identical cluster nodes in terms of both hardware and software. Usually, they are equipped with a standardized software stack: Unix-like operating system, Message Passing Interface (MPI [8]), or Parallel Virtual Machine (PVM) [9]. Nowadays, cluster size ranges from a couple of nodes up to tens of thousands. In the context of Big Data, clusters can be considered as the most popular infrastructure for Big Data management. By federating resources, a cluster can potentially provide a decent amount of storage space for hosting Big Volume of data. In high-end clusters, datamanagement systems obviously can take advantages of its powerful processing capability of its cluster nodes as well as the high-speed interconnection among them to cope with the Big Velocity characteristic. 2.2.1 Commodity clusters The main goal of commodity cluster computing (or commodity computing) is to deliver high computing power at low cost by federating already available computer equipments. The idea consists in using more low-performance, low-cost hardware that work in parallel rather than high-performance, high-cost hardware in smaller numbers. Therefore, commodity hardware components are mostly manufactured by multiple vendors and are incorporated based on open standards. Since the standardization process promotes lower costs and12 Chapter 2 – Current infrastructures for Big Data management identical products among vendors, building commodity cluster avoids the expenses related to licenses and proprietary technologies. Low-cost hardware usually comes along with low reliability. In a system made out of a large number of poorly reliable components, failure is not an exception but the norm. For this reason, middleware running on commodity cluster needs to deal with fault-tolerance by design. A successful example of middleware for commodity cluster is MapReduce framework, introduced by Google in [4, 10]. To date, the largest cluster systems are built by industry giants such as Google, Microsoft and Yahoo! in order to cope with their massive data collections. Those clusters are made out of commodity machines, running a MapReduce or MapReduce-like framework. Google has not revealed the size of its infrastructures, but it is widely believed [11] that each cluster can have tens of thousands nodes interconnected with just standard Ethernet links. 2.2.2 High performance computing (HPC) clusters (a.k.a. Supercomputers In contrast to commodity computing, HPC clusters are made by high-cost hardware (e.g., IBM Power7 RISC) that are tightly-coupled by high-speed interconnection networks (e.g., InfiniBand). Currently, the second fastest HPC cluster in the world is Japan’s K computer [12]. K computer, produced by Fujitsu, consists of over 80,000 high-performance nodes (2.0 GHz, 8-core SPARC64 VIIIfx processors, 16 GB of memory), interconnected by a proprietary sixdimensional torus network. HPC clusters are actively used in different research domains such as quantum physics, weather forecasting, climate research, molecular modeling and physical simulations. Those problems are highly compute-intensive tasks which need to be solved in a bound completion time. HPC cluster can offer an excellent infrastructure to dispatch a job over a huge number of processes and guarantee efficient interconnection between them for each computation step. 2.3 Grids By definition in [13], the term “the Grid” refers to a system that coordinates distributed resources using standard, open, generalpurpose protocols and interfaces to deliver nontrivial qualities of service. In [14], Ian Foster differentiated “Grids” from other type of distributed systems by a threepoint requirements checklist. To coordinate distributed resources. A Grid must integrate and coordinate not only resources but also Grid users within different administrative domains. This challenge brings new issues of security, policy and membership administration which did not exist in locally managed system. To use standard, open, general-purpose protocols and interfaces. A Grid should be built from standard, open protocols and interfaces to facilitate the integration of multiple organizations and to define a generic well-built framework for different kind of Grid2.3 – Grids 13 applications. A Grid covers a wide range of fundamental issues such as authentication, authorization, resource discovery, and resource access. To deliver nontrivial quality of service. Grid resources should be used in a coordinated fashion to deliver various qualities of services such as response time, throughput, availability, and security. Grid allows co-allocation of various resource types to satisfy complex user demands, so that the utility of combined system is significantly better than just a sum of its components. The term “Grid” originates from an analogy between this type of distributed infrastructure and the electrical power Grid: any user of the Grid can access the computational power at any moment in a standard fashion, as simple as plugging an electrical equipment into an outlet. In other words, a Grid user can submit a job without having to worry about its execution or even knowing the usage of resources in the computation. This definition has been used in many contexts where it is hard to understand what a Grid really is. One of the main powerful features of Grid is the introduction of “virtual organization” (VO), which refers to a dynamic set of disparate groups of organizations and/or individuals agreed on sharing resources in a controlled fashion. VO clearly states under which conditions resources including data, software, and hardware can be shared to the participants. The reason behind is that the sharing resources will be seen transparently by applications to perform tasks despite their geographically disparate providers. In the context of Big Data, a Grid infrastructure has the potential to address (but is not limited to) a higher scale of Big Volume than a cluster does. As a Grid can be built from many clusters of different administration domains, it can expose an aggregate storage space for storing a huge amount of data that cannot fit to any participant clusters in the Grid. 2.3.1 Grid architecture The goal of Grid is to be an extensible, open architectural structure that can adapt to the dynamic, cross-organizational VO managements. Ian Foster argued on a generic design that identifies fundamental system components, the requirements for these components, and how these components interact with each other [13]. Architecturally, a Grid organize components into layers, as on Figure 2.1. Grid Fabric provides the lowest access level to raw resources including both physical entities (clusters, individual computers, sensors, etc.) and logical entities (distributed file systems, computational powers, software libraries). It implements the local, resourcespecific drivers and provides a unified interface for resources sharing operations at higher layers. Connectivity guarantees easy and secure communication between fabric layer resources. The connectivity layer defines core communication protocols including transport, routing, and naming. Authentication protocols enforce security of communication to provide single sign-on, user-based trust relationships, delegation and integration with local security solutions. Those protocols must be based on existing standards whenever possible.14 Chapter 2 – Current infrastructures for Big Data management !""#$%&'$() *(##+%'$,+ -+.(/0%+ *())+%'$,$'1 20$345&60$% Figure 2.1: Generic Grid architecture. Resource is built on top of connectivity layer using communication and authentication protocols provided to define protocols that expose individual resources to the Grid participants. The resource layer distinguishes two primary protocol classes: information protocols and management protocols. Information protocols are used to query the state of a resource such as its configuration, current load, and usage policy. Management protocols apply to negotiate access to a shared resource, and to specify resource requirements. Collective does not associate with any single specific resource but instead coordinate individual resources in collections. It is responsible to handle resource discovery, scheduling, co-allocation, etc. Application consists of user applications that make use of all other layers to provide functionalities within a VO environment. 2.3.2 Grid middleware Grid middleware are general-purpose Grid software libraries that provide core components and interfaces for facilitating the creation and management of any specific Grid infrastructure. A lot of effort from industrial corporations, research groups, university consortiums has been dedicated to developing several Grid middleware. Globus. The Globus Toolkit [15] has been developed by the Globus Alliance as a de-facto standard Grid middleware for the construction of any Grid infrastructure. Following a modular-oriented design, the Globus Toolkit consists of several core Grid components made up a common framework to address security, resource management, data movement, and resource discovery. The Globus Toolkit enables customization by allowing users to expose only desired functionalities when needed. For this reason, the Globus Toolkit has been widely adopted both in academia and industry such as IBM, Oracle, HP, etc. UNICORE. UNiform Interface to COmputing REsources (UNICORE) [16] is a Grid middleware currently used in many European research projects, such as EUROGRID, GRIP,2.4 – Clouds 15 VIOLA, Open MolGRID, etc. UNICORE has been developed with the support of the German Ministry for Education and Research (BMBF) to offer a ready-to-run Grid system including both client and server software. Architecturally, UNICORE consists of three tiers: user, UNICORE server and target system. The user tier provides a Graphical User Interface (GUI) that enables intuitive, seamless and secure access to manage jobs running on the UNICORE servers. The UNICORE servers implement services based on the concept of Abstract Job Object (AJO). Such an object contains platform and site-independent description of computational and data-related tasks, resource information and workflow specifications. The target tier provides resource-dependent components that expose interface with the underlying local resource management system. gLite. The Lightweight middleware [17] was developed as part of the flagship European Grid infrastructure project (EGEE) [18], under the collaborative efforts of scientists in 12 different academic and industrial research centers. The gLite middleware has been used to build many large-scale scientific Grids, among them is the Worldwide LHC Computing Grid deployed at CERN. gLite was initially based on the Globus toolkit, but it eventually evolved into a completely different middleware specialized to improve usability in production environments. To this end, users are supplied with a rich interface to access to a variety of management tasks: submitting/canceling jobs, retrieving logs about job executions, uploading/deleting files on the Grid, etc. 2.4 Clouds Cloud computing is an emerging computing paradigm that has attracted increasing attention in recent years, especially in both Information technology and Economy community. The media as well as computer scientists have a very positive attitude towards the opportunities that Cloud computing is offering. According to [19], Cloud computing is considered “no less influential than e-business”, and it would be the fundamental approach towards Green IT (environmentally sustainable computing). Several authors tried to find a clear definition of what Cloud computing is and how it is positioned with respect to Grid computing [20, 19]. Although there are many different defi- nitions, they share common characteristics. Cloud computing refers to the capability to deliver both software and hardware resources as services in a scalable way. Architecturally, the core component of Cloud is the data center that contains computing and storage resources in term of raw hardware, together with software for lease in a pay-as-you-go fashion. In [21], Berkeley RAD lab defined Cloud computing as follows: Cloud computing refers to both the applications delivered as services over the Internet and the hardware and systems software in the datacenters that provide those services. The services themselves have long been referred to as Software as a Service (SaaS). The datacenter hardware and software is what we will call a Cloud. When a Cloud is made available in a pay-as-you-go manner to the general public, we call it a Public Cloud; the service being sold is Utility computing. We use the term Private Cloud to refer to internal datacenters of a business or16 Chapter 2 – Current infrastructures for Big Data management other organization, not made available to the general public. Thus, Cloud computing is the sum of SaaS and Utility computing, but does not include Private Clouds. People can be users or providers of SaaS, or users or providers of Utility computing [21]. From a hardware point of view, Cloud computing distinguishes itself from other computing paradigms in three aspects. • There is no need for Cloud computing users to plan the future development of IT infrastructure far ahead. Cloud offers the illusion of unlimited computing resources to users. • Cloud users can start renting a small amount of Cloud resources and increase the volume only when their needs increase. • Cloud providers offer the ability to pay-as-you-go on a short-term basis. For instance, processors and storage resources can be released if no longer useful in order to reduce costs. Regarding to Grid computing, Cloud computing is different in many aspects as security, programming models, data models and applications, as argued by Foster et al. [22]. Cloud computing leverages virtualization to separate the logical layer from the physical one, that consequently maximizes resource utilization. Whereas Grid achieves high utilization through fair sharing of resources among organizations, Cloud potentially maximizes resource usages by allowing concurrent isolated tasks running on one server, thank to virtualization. Cloud gives users the impression that they are allocated dedicated resources scaling on demand, even though in a shared environment. In the context of Big Data, Cloud computing is a perfect match for Big Data since it virtually provides unlimited resources on demand. Cloud computing opens the door to Big Data processing for any user that may not have the possibility to build it-own infrastructures such as Clusters, Grids, etc. By renting resources, Cloud users can potentially perform their Big Data processing in an economic way. 2.4.1 Cloud Architecture Architecturally, Cloud computing may consist of three layers, as illustrated on Figure 2.2. Infrastructure as a Service (IaaS). IaaS offers on-demand raw hardware resources such as computing power, network and storage in the form of virtualized resources. Users of IaaS Clouds typically rent customized virtual machines in which they have the possibility to select a desired Operating System (OS), and to deploy arbitrary software with specific purposes. Fees are charged with a pay-as-you-go model that reflects the actual amount of raw resources consumption: storage space per volume, CPU cycles per hour, etc. Examples of IaaS Cloud platforms include: Nimbus [23], Eucalyptus [24], OpenNebula [25], and Amazon Elastic Compute Cloud [26].2.4 – Clouds 17 !"#$%&'()&*)&)*('+,-(./!&&!0 12&$#"'3)&*)&)*('+,-(./1&&!0 45#'&*$'6-$6'()&*)&)*('+,-(./4&&!0 Figure 2.2: Cloud services and technologies. Platform as a Service (PaaS). PaaS sits on top of IaaS to provide a high-level computing platform typically including Operating System, database, programming and execution environment. PaaS targets software developers. Using PaaS, they can design their applications using specific frameworks provided by the platform without the need of controlling the underlying hardware infrastructure (IaaS). Examples of PaaS are Google App Engine [27], Microsoft Azure [28], and Salesforce [29]. Software as a Service (SaaS). At the highest level, SaaS is the most visible layer for endusers. It delivers software as a service by allowing users to directly use applications deployed on Cloud infrastructure. Cloud users do not need to worry about installing software on their own computer and managing updates and patches. Typically, any Web browser can act as frontend of Cloud applications. Examples of SaaS Clouds are Google Docs [30] and Microsoft Office Live [31], etc. 2.4.2 Cloud middleware As the most attractive computing technology in recent years, Cloud middleware have been under heavy development in academia as well as in industry. Several Clouds offered by industry giants such as Amazon, Google, IBM, Microsoft already matured as commercial services under massive utilization. We survey some examples of Infrastructure as a Service (IaaS) Cloud. Amazon EC2. EC2 [26] is an Infrastructure as a Service (IaaS) Cloud that has become the most widely-used commercial Cloud. It offers rich functionalities. Amazon EC2 provides resizable virtual computing environments by allowing user to rent a certain amount of compute and storage resources, and dynamically resize them on demand. Though a Web service interface, Amazon users can launch virtual machines with a range of selected operating systems (OS). Then, users are capable to load customized applications and run their own computations. Amazon also delivers S3 [32], a Cloud storage service that offers a simple access interface for data transfers in and out of the Cloud. S3 not only stores users’ data but also acts as a large pool of predefined Amazon Machine Images (AMIs). Upon requested, an AMI will be load to run on allocated virtual machines, or be customized to form a new AMI.18 Chapter 2 – Current infrastructures for Big Data management Nimbus. Nimbus [23] is an open-source EC2/S3-compatible IaaS implementation that specifically targets scientific community, with the purpose of being an experimental testbed tailored for scientific users. Nimbus shares similar features with Amazon EC2 by exposing an EC2-like interface to enable the ease-of-use. The data-storage support of Nimbus, Cumulus [33] is also compatible with the Amazon S3. Cumulus can access various storage management systems and exposes S3-interface for storing users’ data and customized images of virtual machines. Eucalyptus. Eucalyptus [24] stands for Elastic Utility Computing Architecture Linking Your Programs To Useful Systems. Eucalyptus enables the creation of private IaaS Clouds with minimal effort without the need for introducing any specialized hardware or retooling existing IT infrastructure. By maintaining the de-factor Amazon Cloud API standards, Eucalyptus supports the construction of hybrid IaaS Clouds to bridge private to public Cloud infrastructures. Additionally, Eucalyptus is one among the few Cloud middleware that feature a virtual network overlay implementation that provides network isolation. This design brings two major benefits. First, network traffic of different users is isolated to mitigate interference. Second, resources on different clusters are unified to give users the impression that they belong to the same Local Area Network (LAN). 2.5 Big memory, multi-core servers As hardware technology is subject to continuous innovation, computer hardware are made more and more powerful than their prior models in the past. One obvious demonstration is the application of Moore’s law. Gordon E. Moore, Intel co-founder, claimed in his 1965 paper [34] that the number of components in integrated circuits had doubled every two years and predicted the trend would continue in the near future. The relation of price versus capacity has decreased exponentially over time in storage industry, not only in hard-drives but also in main-memory development. For example, 1 MB of main memory dropped at US $0.01 in 2010, which is an impressive decrease in comparison with the price at about US $100 in 1990. A similar observation can be also found in harddrive industry. In 2012, a typical high-end server could be equipped with 100 GB of main memory and hundreds of TBs of storage space. The near future will see the existence of 1 TB main memory and more in a single server. In the processor industry of the past, the speed of Central Processing Units (CPUs) had been doubled every 20 months on average. This brilliant achievement had been made possible thanks to two major factors. First, the creation of faster transistors results in increased clock speed. Second, the increased number of transistors per CPU not only made processor production more efficient but also decreased material consumption. More specifically, the number of transistors on a processor increased from 2300 transistors in 1971 to about 1.7 billion today at approximately the same price. However, since 2002, the growth in clock speed stopped as it had done for almost 30 years. Moore’s law on CPU speed has reached its limit due to power consumption, heat distribution and speed of light [35]. As a result, latest trends on CPU design highlighted the emergence of multi-core/many-core and multiprocessor architectures. The clock speed ofCPU is no longer on Moore’s law, but the number of cores/processors on a single computer is. 2.6 Summary This chapter presented the explosion of Big Data and a survey of current infrastructures in the context of Big Data management. First, we presented a clear definition of Big Data by “the 3V characteristics of Volume, Velocity and Variety”. Second, we introduced some of current infrastructures that can be leveraged for Big Data management, including clusters, Grids, Clouds, and multi-core, multiprocessor servers with large memory. However, to be able to deliver high scalability for Big Data management, we need to have a good understanding of how current storage systems have been designed and what are the main approaches for scalability. These aspects are addressed in the next chapter.20 Chapter 2 – Current infrastructures for Big Data management21 Chapter 3 Designing data-management systems: Dealing with scalability Contents 3.1 Scaling in cluster environments . . . . . . . . . . . . . . . . . . . . . . . . . 22 3.1.1 Centralized file servers . . . . . . . . . . . . . . . . . . . . . . . . . . . 22 3.1.2 Parallel file systems . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23 3.1.3 NoSQL data stores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25 3.2 Scaling in geographically distributed environments . . . . . . . . . . . . . 26 3.2.1 Data Grids . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26 3.2.2 Scalability concerns and trade-offs . . . . . . . . . . . . . . . . . . . . 27 3.2.3 Examples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28 3.3 Scaling in big-memory, multi-core servers . . . . . . . . . . . . . . . . . . . 28 3.3.1 Current trends in scalable architectures . . . . . . . . . . . . . . . . . 28 3.3.2 Examples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29 3.4 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30 S CALABILITY is defined as the ability of a system, network, or process, to handle growing amount of work in a capable manner, or its ability to be enlarged to accommodate that growth [36]. This definition of scalability can be applied to asset whether that a system is scalable or not on a specific system property. For example, scalable data throughput refers to the capability of a system to increase total throughput when resources are added in order to handle an increased workload.22 Chapter 3 – Designing data-management systems: Dealing with scalability A system that has poor scalability can result in poor performance. In many cases, adding more resources to an unscalable system is an inefficient investment that cannot lead to substantial improvements. Obviously, scalability by design is needed, especially for Big Data management systems. There are two ways to scale. Scale horizontally. This approach is usually referred to as “scale-out”, meaning to add more nodes to a system, for instance new computing nodes to a cluster. As computer prices drop, a powerful computing cluster can be built by aggregating low-cost “commodity” computers connected via a local network. By following the “divide-to-conquer” model where each node is assigned only a subset of the global problem, the cluster can be easily scaled to a certain number of worker nodes, to adapt to each particular problem size. Scale vertically. In this approach, resources are added to some nodes in a system, typically meaning to add more CPU and more memory to each node. This is usually referred to as “scale-up”, which enables running services, both user and kernel levels, to have more resources to consume. For example, adding more CPUs allows more threads to be run simultaneously. Adding more memory can enlarge the cache pool to reduce accesses to secondary storage. In this chapter, we present several existing approaches to scalable design of datamanagement systems, whose goal is to store and retrieve data in an efficient way. Thus, we will not go into details about other aspects such as fault-tolerant design of those systems. We classify the mechanisms in three levels: cluster (horizontal scaling), geographically distributed sites (horizontal scaling) and single server (vertical scaling). 3.1 Scaling in cluster environments 3.1.1 Centralized file servers In cluster environments, the most basic form of shared data storage among cluster nodes is Network-Attached Storage (NAS). NAS refers to a single dedicated machine that directly connects to block-based storage devices. It is responsible for all data accesses issued by other machines in the cluster. In this setting, this dedicated machine communicates to blockbased storage devices through I/O bus protocols such as IDE, SATA, SCSI and Fiber channel, while exposing a file system interface to file system clients. In order to do so, it has to maintain a mapping between its file system’s data structures (files and directories) and the corresponding blocks on the storage devices. This extra data for this mapping is commonly named metadata and the dedicated machine is usually called a network file server. In order to access a network file server through the network, the network file server and file system clients agree on standardized network protocols, such as the Network File System protocol (NFS) for communication. By using such a protocol, the clients can access a remote file systems in the same way as accessing its local file systems. The first type of block-based storage devices that can be attached to network file servers is Direct-attached storage (DAS) [37]. To be able to scale the system, either for storage capability3.1 – Scaling in cluster environments 23 or/and for performance, DAS can be employed within a RAID [38] (Redundant Array of Independent Disk) setting. RAID is capable to combine multiple block-based storage units into a single logical unit. RAID can organize a distribution scheme in one of several ways called “RAID levels”, depending on each user’s particular requirements in terms of capacity, redundancy and performance. For example, RAID 0 refers to a simple block-level striping over a number of storage devices without any fault-tolerance mechanism such as parity or mirroring. Consequently, RAID 0 offers the best performance but no fault-tolerance. Upon writing, data is fragmented into same-size blocks that are simultaneously written to their respective drives. Upon reading, data blocks are fed in parallel to achieve increasing data throughput. Another technology to scale the storage capability in NAS configurations is to employ a centralized server on top of a storage-area network (SAN). A SAN features a high performance switched fabric in order to provide a fast, as well as scalable interconnect for a large number of storage devices. One observation is that in both cases where NAS is implemented on top of a SAN or DAS, the performance of the entire system is limited by the performance of the single file server. SAN file systems [39], have been introduced to address the aforementioned issues. In a SAN file system setting, the clients are also connected to a SAN to directly access data through block-based protocols (e.g., iSCSI). Hence, the file server is only responsible for metadata management, reducing I/O overhead and increasing the overall performance. IBM SAN file system [40], EMC High-Road [41] are good examples of existing SAN file systems. Although the approach of SAN file systems seems to be scalable, it is practically hard and expensive to build a SAN at a large-scale, compare to other cross-platform approaches such as parallel file systems. In 2007, IBM discontinued selling the SAN file systems and replaced it by IBM General Parallel file system (GPFS) [42]. 3.1.2 Parallel file systems Parallel file systems are the most appropriate solution for data sharing in a high-performance computing (HPC) cluster, as they offer a higher level of scalability than centralized storage solutions. Parallel file systems also have the advantage of transparency, which allows any clients to access data using standardized network protocols. The clients do not have to possess a dedicated access to the underlying storage resources (e.g., Fiber channel). To scale out, a parallel file system typically federates multiple nodes, each of which contributes its individual storage resources. Those nodes are called I/O nodes serving data to client/compute nodes on the cluster. Parallel file systems implement a well-known mechanism, called data stripping, to distribute large files across multiple I/O nodes, which greatly increases scalability in terms of both capacity and performance. First, it allows to store very large files far than the capacity of any individual I/O node in the cluster. Second, reading and writing can be served in parallel by multiple servers, which reduces the actual workload on each particular server. Another scalable design aspect in parallel file systems refers to breaking I/O in two phases: metadata I/O and data I/O, so as to offload data I/O totally to storage nodes. Metadata refer to the information about file attributes and file content locations on the I/O nodes. Since this information is comparatively smaller than file data itself, metadata I/O workload24 Chapter 3 – Designing data-management systems: Dealing with scalability can be handled in a centralized way in one special node, named Metadata server (MDS). Recently, it is claimed that a centralized MDS can be a bottleneck at large-scale [43, 44]. Many novel parallel file systems moved forward to a distributed design MDS [45, 44]. However, scalable MDS approaches face bigger challenges and are still under active research. A typical trade-off when designing a distributed MDS scheme is the need to choose between POSIX [46] file access interface and scalable performance. The highly standardized interface enables a high level of transparency, allowing clients to have the same I/O semantics as in local file systems. On the other hand, its strict semantics is hard to guarantee in a distributed environment, and limits the system scalability. 3.1.2.1 PVFS Parallel virtual file system (PVFS) was first introduced in 2000 [45] as a parallel file system for Linux clusters. It is intended to provide high-performance I/O to file data, especially for parallel applications. Like other parallel file systems, PVFS employs multiple user-level I/O daemons, typically executed on separate cluster nodes which have local disks attached, called I/O nodes. Each PVFS file is fragmented into chunks and distributed across I/O nodes to provide scalable file access under concurrency. PVFS deploys one single manager daemon to handle metadata operations, such as directory operations, distribution of file data, file creation, open, and close. In this case, clients can perform file I/O without the intervention of the metadata manager. Although metadata I/O seems to be less heavy than file I/O, one manager daemon still suffers from low performance under certain workloads, especially when dealing with massive metadata I/O on a huge number of small files. Therefore, a new version of PVFS was released in 2003, featuring distributed metadata management and object servers. To scale metadata on multiple servers, PVFS v2 does not implement a strict POSIX interface, it instead implements a simple hash function to map file paths to server IDs. This may lead to inconsistent states when concurrent modifications occur on the directory hierarchy. 3.1.2.2 Lustre Lustre [47] was started in 1999 with the primary goal of addressing the bottlenecks traditionally found in NAS architectures, and of providing scalability, high performance, and fault tolerance in cluster environments. Lustre is one of the first file systems based on the object storage device (OSD) approach. File data is striped across multiple object storage servers (OSSes), which have direct access to one or more storage devices (disks, RAID, etc.) called object storage targets (OSTs) that manage file data. Lustre exposes a standard POSIX access interface and supports concurrent read and write operations to the same file using locking-based approach. Lustre is typically deployed with two metadata servers (one active, one standby), sharing the same metadata target (MDT) that hosts the entire metadata of the file system. In this setting, Lustre still manages metadata in a centralized fashion, and uses the standby server just in case of failures.3.1 – Scaling in cluster environments 25 3.1.2.3 Ceph Ceph [48], recently developed at University of California, Santa Cruz, is a distributed file system that leverages the “intelligent” and “self-managed” properties of OSDs to achieve scalability. Basically, Ceph delegates the responsibility for data migration, replication, failure detection and recovery to OSDs. File data is fragmented into objects of separate placement groups (PGs), which are self-managed. One novel approach in Ceph is that metadata management is decentralized by an approach based on dynamic subtree partitioning [49]. This distributed management in a metadata cluster potentially favors workload balancing, and eliminates the single point of failure. In contrast to existing object-based file systems [47, 50], Ceph clients do not need to access metadata servers for object placement since this information can be derived by using a con- figured flexible distribution function. This design eliminates the need to maintain object placement on metadata servers, and thus reduces metadata workload. 3.1.3 NoSQL data stores Relational database management systems (RDBMS) have been considered a “one size fits all” model for storing and retrieving structured data along the last decades. RDBMS offer a powerful relational data model which can precisely define relationships between datasets. Under ACID (atomicity, consistency, isolation, durability) semantics, RDBMS guarantee database transactions are reliably processed. They release client applications from the complexity of consistency guarantees: all read operations will always be able to have data from the latest completed write operation. There are several technologies to scale a DBMS across multiple machines. One of the most well-known mechanisms is “database sharding”, which breaks a database into multiple “shared-nothing” “shards” and spread those smaller databases across a number of distributed servers. However, “database sharding” cannot provide high scalability at large scale due to the inherent complexity of the interface and ACID guarantees mechanisms. In 2005 [51], scientists claimed the “one size fits all” model of DBMS had ended and raised the call for new design of alternative highly scalable data-management systems. Many NoSQL data stores have been introduced in recent years such as Amazon Dynamo [52], Cassandra [53], CouchDB [54], etc. They are found by industry to be good fits for Internet workloads. To be able to scale horizontally, NoSQL architectures differ from RDBMS in many key design aspects as briefly presented below. Simplified data model. NoSQL data stores typically do not organize data in a relational format. There are three main types of data access models: key/value, document-oriented, and column-based. Each the data model is appropriate to a particular workload so that the data model should be carefully selected by the system administrators. Key/value stores implement the most simplified data model, which resembles the interface of a hash table. Given a key, key-value stores can provide fast access to its associate value through three main API methods: GET, PUT, and DELETE. Examples of key-value stores include: Amazon Dynamo [52] and Riak [55].26 Chapter 3 – Designing data-management systems: Dealing with scalability Document-oriented stores are designed for managing semistructured data organized as a collection of documents. As in key-value stores, each document is identified by its unique ID, giving access to its content. One of the main defining characteristics that differentiates document-oriented stores from key-value stores is that the interface is enriched to allow the retrieval of documents based on their data fields. Examples are CouchDB [54] and MongoDB [56]. In the column family approach, the data structure is described as “a sparse, distributed, persistent multidimensional sorted map” as in Google’s Bigtable. Data is organized in rows as in RDBMS, but the rows do not need to have the same set of columns. Thus, the data table represents a sparse table with gaps of NULL values. Examples include: Google Bigtable [57] and Cassandra [53]. Complex queries such as JOIN are typically not supported in any of the three approaches. Reducing unneeded complexity. Many Internet applications do not need rich interfaces and the ACID semantics provided by relational databases. These features, which aim at “one size fits all”, are expensive to scale. RDBMS have to either scale vertically (e.g., buy more powerful hardware), or suffer from distributed locking mechanisms and high network latency while scaling horizontally (e.g., add more nodes to the clusters). NoSQL approaches, on the other hand, typically sacrifice ACID properties and complex query support for high scalability. NoSQL usually supports only the eventual consistency model, where read operations are allowed to return stale results but under the guarantee that: all the readers will eventually get the fresh, last written data. Horizontal scaling on commodity hardware. NoSQL data stores are designed to scale horizontally on commodity hardware. They do not rely on highly reliable hardware. Storage nodes can join and leave the storage clusters without causing the entire system to stop functioning. In many NoSQL data stores, the key service to enable scalability is a distributed hash table (DHT). 3.2 Scaling in geographically distributed environments Scalable data-management systems in geographically distributed environments are needed for many reasons. First, large datasets from various scientific disciplines have been growing exponentially and cannot fit in a centralized location. Second, the geographically distributed users may want to share their own datasets without storing in a central repository. In this section, we study the design of geographically distributed data-management systems, focusing on the design for scalability with regard to several key characteristics of the environments: interconnection latency, low bandwidth, heterogeneous resources, etc. 3.2.1 Data Grids Data Grids were first introduced in [58] in a collaborative effort to design and implement an integrated architecture to manage large data that are distributed over geographically distant locations. Typically, this effort directly addressed the challenges of managing scientific3.2 – Scaling in geographically distributed environments 27 data in many disciplines, such as high-energy physics, computational genomics and climate research. The architecture of a Data Grid consists of four layers. Each layer builds its functionality based on the services provided by lower layers and by the components of the same level. Those layers can be described in the following order from the lowest to the highest. Data Fabric consists of the distributed storage resources that are owned by the grid participants, but are aggregated to form a global storage space. Those participant resources can be both software and hardware: file servers, storage area networks, distributed file systems, relational database management systems, etc. Communication provides a number of protocols used to transfer data among resources of the fabric layer. These protocols are built on top of common communication protocols such as TCP/IP and use authentication mechanisms such as the public key infrastructure (PKI). Further, SSL (Secure socket layer) can be used to encrypt the communication to ensure security. Data Grid Services consists of services enabling applications to discover, manage and transfer data within the Data Grid. More precisely, end users are equipped with replication services, data discovery services, and job submission services, which cover all aspects of efficient resource management while hiding the complexity of the Data Grid infrastructure. Applications consists of domain-specific services that facilitate and boost up Data Grid experience. Those services are highly customized and standardized tools for Grid participants. 3.2.2 Scalability concerns and trade-offs Scalability design in Data Grids faces many challenges due to high latency and low bandwidth of interconnection between geographically distributed participants. We briefly survey several design decisions, which address the final goal of building scalable systems. Replication is a well-known mechanism to increase performance by reducing latency, especially in wide-area networks (WAN), and to serve as a fault-tolerance technique by creating multiple copies of the data. In Data Grids, replication is usually performed in a simple way and on a per-request basis. This is done with the purpose of reducing the expensive cost of synchronization via low-bandwidth, high-latency networks in geographically-distributed environments. To maintain the convergence of replicas, Data Grids select a single data source to act as a primary copy of each particular dataset. Upon request of grid participants, a copy of the dataset can be transferred to their own sites. Any update is done on the primary copy and then is propagated to Grid participants that subscribe to the updates on the primary copy. Consistency defines the “freshness” of data seen by applications. Obviously, Data Grids do not provide “strong consistency” guarantees because of their high cost. Strong consistency explicitly requires locking on huge datasets and maintaining synchronous replication, which are not scalable in practice as discussed above.28 Chapter 3 – Designing data-management systems: Dealing with scalability Transaction support refers to the property that all of the processing operations of a transaction are either all succeed together or all failed together. This necessity requires some supports for checkpointing and rollback mechanisms similar to the ones in database management systems (DBMS). However, those mechanisms are not efficient at large scale. Therefore, Data Grids do not support transactions usually. Moving computation close to data. When dealing with huge datasets, Data Grid scientists proposed to move computation close to the data rather than moving the data itself. One example of this interesting approach is the design and implementation of the Gfarm file system [59]. 3.2.3 Examples Grid Data Farm. The Grid Datafarm (Gfarm) [59] is a distributed file system designed for high-performance data access and reliable file sharing in large scale environments, including grids of clusters. To facilitate file sharing, Gfarm manages a global namespace which allows the applications to access files using the same path regardless of file location. It federates available storage space of Grid nodes to provide a single file system image. To enable high performance file I/O, files in Gfarm are fragmented into chunks that are distributed over storage nodes in the grid. Applications can configure the replication factor for each file individually to improve access locality, thus avoiding bottlenecks to popular files on remote sites. Furthermore, Gfarm enables scheduling computations close to data, as it explicitly exposes the location of chunks for each file through a special API at application level. XtreemFS. XtreemFS [60] is an open-source object-based, distributed file system for widearea deployments that enables file accesses over Internet. To mitigate security threats in public insecure networking infrastructures, XtreemFS transparently relies on SSL and X.509 to build secure communications channels between clients and XtreemFS servers. As it is designed for WAN environment, XtreemFS implements file replication to provide fault-tolerance and to reduce data movement across data centers. Additionally, it implements a metadata caching mechanism in order to improve performance over high-latency networks. 3.3 Scaling in big-memory, multi-core servers 3.3.1 Current trends in scalable architectures Currently, there are two major trends in designing scalable data management systems running on high-end servers. In-memory storage systems. Modern servers nowadays are often equipped with large main memory capability that can have a size up to 1 TB. Given this huge memory capability, one question arises: do we need to store data on secondary storage, say hard drives? According to a recent study [5], many data management systems are now able to fit entirely in main memory, so that disk-oriented storage becomes unnecessary (or just useful for backup purposes). The trend of employing an in-memory design emerged3.3 – Scaling in big-memory, multi-core servers 29 as a result of the decreasing cost/size ratio of memory and the performance provided when compared to the disk, as memory accesses are at least 100 times faster. As in-memory design is becoming an attractive key principle to vertically scale datamanagement systems, several commercial in-memory databases have recently developed. Systems such as Timesten [61], VoltDB (the commercial version of H-Store [62]), SAP HANA [63], are able to provide faster, higher-throughput online analytical processing (OLAP) and/or faster transaction processing (OLTP) than disk-based datamanagement systems. The reason behind is that the performance of disk-based systems is actually limited because of the I/O bottlenecks on disk accesses. Of course, disk-based data-management systems can leverage main memory as a big cache to improve I/O performance. However, their architectures optimized to use only disks have to implement complex mechanisms to keep data on cache and on disks consistent. This limits the system scalability by design. Single-threaded execution model. Apart from the in-memory approach, the search for ef- ficient utilization of compute resources in multi-core machines triggered a new architecture for multi-threading applications. Previously, application design was relying on multiple threads to perform tasks in parallel, in order to fully utilize CPU resources. In reality, most of the tasks are not independent of each other, as they either access the same part of data, or they need part of the results generated by the other tasks. This well-known problem (concurrency control) made it nearly impossible to have parallelization fully in a multi-threading environment. Recent data-management systems are based on a single-threaded execution model [64] where there is no need to worry about concurrency control. As long as one single thread is performing data I/O, thread-safe data structures are not necessary. In other words, no locking mechanism is needed, which results in less execution overhead. In the context of multi-core machines, the single-threaded execution model called for a shared-nothing architecture. CPU cores should be used in a way that pure parallelization is guaranteed. The cores should work on unshared data. Examples are HStore [62], and HyPer [65], etc. 3.3.2 Examples H-Store. H-Store [62] is an experimental row-based relational database management system (DBMS) born from a collaboration between MIT, Brown University, Yale University, and HP Labs. H-Store aims at being optimized for online transaction processing (OLTP) applications by leveraging main memory as the persistent storage for fast data accesses. To avoid the overhead of using multi-threaded data structures, H-Store follows the single threaded-execution model where each data structure belongs to one and only one thread. To scale the system in a multi-core server, H-Store relies on a shared-nothing architecture. Each CPU core is delegated one site (a partition of the database) that is the basic atomic entity in the system; each site runs a single-threaded daemon performing transactions independently on its own unshared part of the database stored in the main memory. Additionally, H-Store introduces other optimizations, such as replica-tion over sites and “pre-defined stored procedures”, etc. Pre-defined stored procedures potentially allow H-Store reducing the cost of SQL query analyzing. HyPer. HyPer [65] is a main-memory database management system built to handle both OLTP and OLAP simultaneously on a single multi-core server. HyPer follows the same single-threading approach first recommended in [64], where all OLTP transactions are sequentially handled by one single-threaded daemon. This architecture mitigates the need for concurrent data structures and expensive locking because only one thread owns the entire database. To support OLAP and OLTP simultaneously, HyPer relies on a virtual memory snapshot mechanism that is assisted in hardware by the Operating System (OS). It maintains consistent snapshots for both OLAP and OLTP queries. Upon OLAP request, HyPer clones the entire database and forks a new process using kernel API methods of the OS. This new process is then able to work on that consistent snapshot without any interference with the main database. In multi-core machines, multi OLAP threads can be launched simultaneously as long as they only read on a private snapshot. By relying on hardware mechanisms, HyPer is demonstrated to be fast and high performance. 3.4 Summary In this chapter, we studied the design of data-management systems, focusing on the scalability aspects of their architectures. In order to cope with the increasing workloads, datamanagement systems follow two approaches for scalability: scale horizontally (scale-out), and scale vertically (scale-up). While most of the studied systems are designed to scaleout in distributed environments, recent trends showed promising approaches based on big memory and multi-core to scale-up. Despite there is a huge effort on designing scalable data-management systems, existing approaches face many limitations, especially when dealing with new challenges that arise in the context of Big Data management. For instance, existing storage systems do not efficiently support atomic non-contiguous I/O 1 , which results in poor performance in processing of Big Volume of data. Furthermore, in the context of Big Variety, we observe a shortcoming of specialized storage systems optimized for array-oriented data model. This is also one of the challenges that we will address in this thesis. In the next chapter, we will discuss in detail BlobSeer [66] as an interesting case study of a distributed data-management system. We select BlobSeer because it features several novel key design principles that we rely on this thesis. Next, we present our contributions on designing scalable data-management systems along the classification introduced in this chapter. 1Non-contiguous I/O refers to the access of non-contiguous regions from a file within a single I/O call31 Chapter 4 Case study - BlobSeer: a versioning-based data storage service Contents 4.1 Design overview . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31 4.2 Architecture . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33 4.3 Versioning-based access interface . . . . . . . . . . . . . . . . . . . . . . . . 34 4.4 Distributed metadata management . . . . . . . . . . . . . . . . . . . . . . . 35 4.5 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36 B LOBSEER [66] is a versioning-oriented data sharing service. It is specifically designed to meet the requirements of data-intensive applications that are distributed at a large scale. (1) A scalable aggregation of storage space from a large number of participating machines with minimal overhead. (2) Support to store huge data objects while providing efficient fine-grain access to data subsets. (3) The ability to sustain a high throughput under heavy access concurrency. We selected BlobSeer as a case study for this chapter, as it is the building blocks for a part of our contributions in the context of scalable data storage. 4.1 Design overview BlobSeer is the core project of KerData team, INRIA Rennes, Brittany, France. The main features of BlobSeer are Data as BLOBs, data striping, distributed metadata management and versioning-based concurrency control to distribute the I/O workload at a large scale, and avoid the need for access synchronization both at data and metadata level. As demonstrated by Nicolae et al. [66, 44, 67], these features are crucial in achieving a high aggregated throughput under concurrency.32 Chapter 4 – Case study - BlobSeer: a versioning-based data storage service Data as BLOBs. Data is abstracted in BlobSeer as long sequences of bytes called BLOBs (Binary Large OBject). These BLOBs are manipulated through a simple access interface that enables creating a BLOB, reading/writing a range of size bytes from/to a BLOB starting at a specified offset and appending a sequence of size bytes to the BLOB. BlobSeer addresses the management of huge BLOBs that may grow in size up to terabytes (TB) but also allows fine-grained access to small parts of each BLOB. Therefore, BlobSeer can be a generic storage engine for managing any kind of un-structured data. To facilitate data sharing, BlobSeer maintains a flat namespace in which each BLOB is uniquely identified by a globally shared identifier (ID). Given a BLOB ID, users can access the BLOB’s data in a data-location transparent manner, without being provided the location of that specific BLOB. Data striping. As a BLOB can go beyond TB size, it is impossible and/or inefficient to store each BLOB in a centralized fashion. To improve the performance of data accesses, BlobSeer fragments each BLOB into equally-sized chunks and distributes them across multiple storage nodes. This mechanism is called data striping and is widely used in other distributed data-management systems. Data striping enables load-balancing by directing accesses to different chunks to different storage nodes. Because the value of the chunk size has a big impact on the performance of both writing and reading operations, BlobSeer allows users to configure this parameter independently for each BLOB. The chunk size is specified during the creation of the BLOB and need to be fine-tuned according to the access patterns of the applications. If the chunk size is too large, there is high chance that multiple concurrent readers and writers will access the same chunks. This may create bottlenecks at the local level of the storage servers being accessed. In the inverse case, too small chunk sizes lead to the overhead of initiating many network connections and of transferring many small pieces of data. Distributed metadata management. Since each BLOB is split into chunks stored on a large number of storage nodes, metadata is needed in order to know which chunks belong to which BLOB and where they are located. BlobSeer favors a distributed metadatamanagement scheme based on a Distributed Hash Table (DHT). Metadata pieces are distributed over a number of metadata providers, which alleviates pressure on each particular metadata provider, while a higher aggregated metadata storage capability can be achieved. Additionally, a standard replication mechanism can be built within a DHT to enable higher metadata availability. Versioning-based concurrency control. To achieve high throughput under heavy concurrency, BlobSeer relies on a versioning-based concurrency control. Instead of keeping only the current state (latest version) of each BLOB, BlobSeer remembers any modification on any particular BLOB. Essentially, each modification made by a new WRITE operation results in a new BLOB version. BlobSeer only stores the incremental update that differentiates the current new version from the previous version. Thanks to versioningenabled metadata, each version can be seen as the whole BLOB obtained after a successful WRITE operation. By isolating updates in different versions, concurrent readers and writers can work independently as long as they access distinct BLOB versions. BlobSeer guarantees that4.2 – Architecture 33 Figure 4.1: The architecture of BlobSeer. a new version is unveiled to readers only when it is released by the writer. Concurrent writers are handled in a way that they can perform data I/O in parallel to create various versions, while metadata management is finally responsible for serializing versions in a consistent order. 4.2 Architecture The system consists of distributed processes (Figure 4.1), that communicate through remote procedure calls (RPCs). A physical node can run one or more processes and, at the same time, may play multiple roles from the ones mentioned below. Data providers. The data providers physically store the chunks. Each data provider is simply a local key-value store, which supports accesses to a particular chunk given a chunk ID. Data providers can be configured to use different persistent layers such as BerkeleyDB [68], an efficient embedded database, or just keep chunks in main memory. New data providers may dynamically join and leave the system. Provider manager. The provider manager keeps information about the available storage space and schedules the placement of newly generated chunks. It employs a configurable chunk distribution strategy to maximize the data distribution benefits with respect to the needs of the application. The default strategy implemented in BlobSeer simply assigns new chunks to available data providers in a round-robin fashion. Metadata providers. The metadata providers physically store the metadata that allow identifying the chunks that make up a snapshot version of a particular BLOB. BlobSeer employs a distributed metadata management organized as a Distributed Hash Table (DHT) to enhance concurrent access to metadata.34 Chapter 4 – Case study - BlobSeer: a versioning-based data storage service Version manager. The version manager is in charge of assigning new snapshot version numbers to writers and to unveil these new snapshots to readers. It is done so as to offer the illusion of instant snapshot generation, while guaranteeing total ordering and atomicity. The version manager is the key component of BlobSeer, the only serialization point, but is designed to not involve in actual metadata and data I/O. This approach keeps the version manager lightweight and minimizes synchronization. Clients. BlobSeer exposes a client interface to make available its data-management service to high-level applications. When linked to BlobSeer’s client library, application can perform the following operations: CREATE a BLOB, READ, WRITE, and APPEND contiguous ranges of bytes on a specific BLOB. 4.3 Versioning-based access interface Following the design principles mentioned in Section 4.1, BlobSeer provides a versioningbased access interface to allow clients to manipulate BLOBs with respect to the versioning features, including the creations of a new version and the possibility to read data content of a specific BLOB version. CREATE(id) By invoking the CREATE primitive, a new BLOB with size 0 is created in the system with an identifier id. The BLOB id must be globally unique and is needed for further access operations. WRITE(id, buffer, offset, size) APPEND(id, buffer, size) The WRITE or APPEND primitives modify a BLOB identified by the given id, by writing contents of a buffer of length size at a specified offset or the end of the BLOB. Each function call generates a new version of the BLOB that is assigned a version number, incrementally generated by the version manager. Remember that WRITE and APPEND only allow updating a contiguous range within a BLOB. READ(id, v, buffer, offset, size) A READ operation accesses a contiguous segment (specified by an offset and a size) from a BLOB (specified by its id) and copies it into a given buffer. The desired version of the BLOB from which the segment must be taken can be provided in v. In case v is missing, BlobSeer assumes by default that the latest version of the BLOB is accessed. CLONE(id, v) The CLONE primitive enables users to create a new BLOB based on an existing one. The new BLOB is a “shadow” copy of the version v of the BLOB identified by id. BlobSeer4.4 – Distributed metadata management 35 !"# !"$ $"$ !"% %"% $"% &"% !"# !"$ $"$ %"% $"% !"# $"% &"% $"$ %'()*+,(-+ $./)*+,(-+ +00()! +00()% +00()$ Figure 4.2: Metadata representation: whole subtrees are shared among snapshot versions. implements the CLONE primitive in a smart way that does not require a full copy of data form the source BLOB to the destination BLOB. Instead, CLONE is done at metadata level by simply duplicating the metadata of version v of BLOB id. Furthermore, BlobSeer’s client library also exposes additional primitives for other management purposes such as: switching between BLOB versions, requesting the size of a BLOB, getting the latest available version of a BLOB, etc. 4.4 Distributed metadata management BlobSeer organizes metadata as a distributed segment tree [69]: one such tree is associated to each snapshot of a given BLOB id. A segment tree is a binary tree in which each node is associated to a range of the BLOB, delimited by offset and size. We say that the node covers the range (offset, size). The root covers the whole BLOB snapshot, while the leaves cover single chunks (i.e., keep information about the data providers that store the chunk). Chunk size (in the order of KBs) is a configurable parameter per BLOB. For each node that is not a leaf, the left child covers the first half of the range, and the right child covers the second half. The segment tree itself is distributed at fine granularity among multiple metadata providers that form a DHT (Distributed Hash Table). This is done for scalability reasons, as a centralized solution becomes a bottleneck under concurrent accesses. In order to avoid the overhead of rebuilding the whole segment tree for each new snapshot (which consumes both space and time), entire subtrees are shared among the snapshots, as shown on Figure 4.2. Root 0 represents the initial metadata tree for a BLOB consisting of 4 chunks. In our example, chunk size is set to 1 so that the root covers the range (0,4) (offset = 0, and size = 4 chunks). If we follow down the tree, each of the two inner nodes covers 2 chunks while each leaf covers exactly only one chunk. Regarding the metadata trees represented by root 1 and root 2, those trees belong to different snapshots but they shared leaves and inner nodes between them and with root 0. To understand how BlobSeer’s metadata management gets involved in I/O operations, especially in concurrency, we study two main cases: reading and writing.36 Chapter 4 – Case study - BlobSeer: a versioning-based data storage service WRITE. On writing, the client first contacts the provider manager to get a list of available data providers. It then splits the data range in chunks and distributes them over the given data providers in parallel. When chunks are written, the client contacts the version manager to get a version for the new write. Next, the metadata tree for the new snapshot is generated in a bottom-up fashion, starting from leaves to the new root. Because of concurrency, multiple clients can simultaneously contact the version manager. For example in Figure 4.2, let us assume that there are two concurrent writers on the BLOB version identified by root 0 (version 0). BlobSeer relies on the version manager to decide upon the order of concurrent writers. Indeed, the version manager is the single serialization point of BlobSeer where concurrent writes are assigned new versions in a first comes first served order. In our example, the first writer gets version 1, the second writer gets version 2. Since the metadata nodes are shared across three versions 0, 1, and 2, we may think that concurrent writers cannot generate metadata trees in parallel. To enable the parallelization in generating metadata trees, the version manager not only assigns a version for each writer, but also informs it about the given versions and the access ranges of the possible concurrent writers with lower version numbers. Based on this information, each writer can guess which the tree nodes will be generated by other writers. It can be done by calculating the identity of a tree node, which is just a triple of version, offset, size. Therefore, each writer can generate its metadata tree in an isolated fashion on the assumption that the shared tree nodes will be eventually generated by the other writers. READ. On reading, the client first contacts the version manager to specify which version of which BLOB it wants to access. If that version exists, then the version manager sends back the root of the corresponding metadata tree to the client. Starting from the given root, the client can traverse down the metadata tree to the leaves that cover the desired data range. The client can then use the information in tree leaves to know which data providers it has to access for data. Because metadata and data are never overwritten as explained in data writing, multiple readers can read simultaneously even in parallel with writers, as long as BlobSeer keeps writers and readers not accessing the same version. It is the role of the version manager to unveil new version to readers only when the writer have finished to generate it. 4.5 Summary This chapter describes the design and the architecture of BlobSeer, a versioning-oriented large-scale data-management service. BlobSeer targets data-intensive distributed applications that need to manage massive unstructured data at large-scale. We focused on BlobSeer’s distributed metadata management and its versioning-oriented interface as they are the main novel features in comparison with other distributed storage systems. By using BlobSeer as a building block for some of our systems, we were able to speed up the creation of several prototypes in order to validate our contributions. These are discussed in the next chapters.37 Part II Scalable distributed storage systems for data-intensive HPC39 Chapter 5 Data-intensive HPC Contents 5.1 Parallel I/O . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40 5.1.1 Parallel I/O stack . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40 5.1.2 Zoom on MPI-I/O optimizations . . . . . . . . . . . . . . . . . . . . . 41 5.2 Storage challenges in data-intensive HPC . . . . . . . . . . . . . . . . . . . 42 5.3 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44 I N this chapter, we address the common practices in data-intensive HPC and highlight some challenges in building scalable storage systems in such an environment. High performance computing (HPC) plays an important role in our modern life nowadays. We depend on HPC for a wide range of activities in both nature and social sciences. Researchers in institutes, universities and government labs use HPC systems and applications to study weather and climate, bioscience, chemistry, energy, etc. Engineers in industries rely on HPC to design almost every product we use. HPC exists in mechanical simulation, package design, automotive manufacturing, volcanic simulation, financial simulation, etc. There is no clear definition of HPC. It refers to the use of parallel processing on high performance clusters for efficiently solving advanced problems that cannot be afforded by a single machine. Those advanced problems often set strict constrains on timing and precision so that a huge amount of resources is needed to address them. Because of its nature to address big problems, HPC applications are increasingly becoming data-intensive. High-resolution simulations of natural phenomena (Cloud Model 1 - CM1 [70]), climate modeling (Weather Research and Forecasting Model - WRF [71]), largescale image analysis, etc. generate and consume large volumes of data. Such applications currently manipulate data volumes in the Petabyte scale. With the growing trend of data sizes, we are rapidly advancing towards the Exabyte scale. In the context of Big Data, dataintensive HPC applications emphasize the Big Volume aspect.40 Chapter 5 – Data-intensive HPC !""#$%&'$()*+,-$*.'/+0123 4&'&+1(56#+,7489/+)6'0483 1:.;.<=+1$55#6>&?6 :&?&##6#+@$#6+*A*'6B*+,:-8C/+D:8C3 Figure 5.1: Typical parallel I/O stack. 5.1 Parallel I/O Data-intensive HPC applications typically consist of a massive number of processes deployed on the compute nodes of HPC clusters. During each execution, those processes need to coordinate for reading input data and writing output data. A typical execution may also involve checkpointing where the intermediate data state is written to the underlying storage systems after a number of iteration steps. In all of those cases, the I/O access pattern exhibited by data-intensive HPC applications relies to parallel I/O. 5.1.1 Parallel I/O stack A typical parallel I/O stack consists of 4 layers, as shown on Figure 5.1. Parallel file systems. At the lowest level is the parallel file system that provides efficient access to data files. Its role is to federate storage resources and to coordinate accesses to files and directories in a consistent manner. The parallel file system exhibits a global file system namespace that is typically seen through a UNIX-like interface. Some parallel file systems such as PVFS allow users to access not only contiguous regions of files but also non-contiguous regions. This extension is provided to favor access patterns generated by parallel applications on upper layers. I/O Middleware: MPI-I/O. On top of the parallel file system typically sits the MPI-I/O implementation, whose interface is part of the MPI-2 interface specification [72]. MPI-I/O middleware defines a standard API and implements several optimizations such as collective I/O and data caching. By providing a standard API, MPI-I/O can leverage file system-specific interfaces and optimizations transparently to upper layers. The role of MPI-I/O middleware is to translate accesses generated by applications or high-level I/O libraries to file system-specific accesses that can be performed efficiently by the underlying file systems. High-level I/O library (data model). The MPI-I/O interface provides performance and portability, but the interface is relatively limited as it only allows accesses to unstructured data. However most of data-intensive scientific applications work with structured data, and MPI-I/O is simply not sufficient to represent those complex data models. For this reason, high-level I/O libraries installed on top of MPI-I/O middleware5.1 – Parallel I/O 41 are necessary (e.g., Parallel HDF5 [73] or netCDF [74]). They allow applications to describe easier complex data models. Parallel applications. Parallel applications such as VisIt [75], Cloud Model 1 - (CM1) [70] rely on the I/O interface provided by the MPI-I/O middleware or by a high-level I/O library to access data in parallel file systems. 5.1.2 Zoom on MPI-I/O optimizations MPI (Message Passing Interface) [8] is the dominant parallel programing model in HPC environments. Among MPI components, MPI-I/O [76] was developed to standardize the parallel I/O access interface with the goal to achieve portability and performance. Non-contiguous I/O. MPI-I/O allows accessing non-contiguous regions from a file into non-contiguous memory locations within a single I/O call. Although most parallel file systems do not implement this I/O functionality, non-contiguous I/O accesses are common in many data-intensive HPC applications [77, 78]. Therefore, the ability to specify non-contiguous accesses in MPI-I/O helps bridging the gap between parallel file system interface and application needs. In the case parallel file system does not support non-contiguous I/O, one simple approach to implement a non-contiguous I/O is to access each contiguous portion separately by using regular I/O function calls of the file system. However, such implementation does not feature any optimization. It often results in a large number of independent small I/O requests to the underlying file system, eventually degrading drastically I/O performance. ROMIO, a MPI-I/O implementation, performs an optimization for non-contiguous I/O accesses by using a mechanism called data sieving. This mechanism tries to make a single contiguous I/O request to the file system that covers the first requested byte up to the last requested byte. On reading, the output data is then stored in temporary buffer in main memory, and only the requested non-contiguous portions are extracted and copied to user’s buffer. On writing, a read-before-write is performed in order to make up a contiguous region to write to the file. Collective I/O. Almost all parallel file systems only support independent I/O. As they provide only Unix-like interface, successive I/O requests are independently served in an isolated fashion. In context of large parallel computing where multiple distributed processes coordinate to solve a big problem, this form of I/O access does not capture the global picture of the access patterns, ignoring precious information for optimization. One of the most important access optimizations done in the MPI-I/O specification is collective I/O. In contrast to independent I/O, collective I/O leverages the global information about parallel processes to merge, to aggregate accesses in order to favor the underlying storage system. Such optimizations can significantly improve I/O performance. There are several advantages in performing I/O collectively. First, concurrent I/O requests that are overlapped and redundant can be filtered. Second, collective I/O can merge, aggregate many small and non-contiguous requests into smaller number42 Chapter 5 – Data-intensive HPC of large and contiguous I/O calls. Although each individual process may perform non-contiguous I/O, it is likely that the requests of multiple processes constitute one or many larger contiguous portions of a file. In this sense, collective I/O eliminate non-contiguous patterns on the underlying systems. The default mechanism to implement collective I/O in ROMIO implementation refers to a two-phase strategy [79]. A communication phase must happen before the I/O phase. Its role is to aggregate I/O requests and to decide which processes will perform which parts of the aggregated I/O in the second phase. In other words, ROMIO tries to propose an optimized I/O access strategy based on the global information of concurrent accesses. 5.2 Storage challenges in data-intensive HPC As I/O is the new bottleneck, storage system for HPC is becoming a critical factor of the overall performance of applications. Unless using an appropriate storage architecture, application performance will be disappointing regardless of how powerful the HPC cluster is. This section presents several storage challenges that are critical and relevant to our contribution. Massive data size. Data-intensive HPC applications tend to generate and consume an amount of data that is dramatically increasing. Such applications currently manipulate data volumes in the Petabyte scale and with the growing trend of data sizes we are rapidly advancing towards the Exabyte scale. Facing this rapid pace, current I/O architectures and system designs are often overwhelmed by this immensity of data. Obviously, the poor I/O throughput of the underlying storage systems creates bad impacts on the overall performance of HPC environments. One question has been raised: “How do we efficiently manage this huge amount of data that our current storage architecture was not designed for?” CHALLENGE: How to manage massive data size efficiently? Massive parallelization. Together with the growth of the data volumes generated by applications, the number of parallel processes participating in the computation is increasing dramatically. Today, scientific simulation applications such as cloud modeling CM1 easily scale to tens of thousand processes. During each computation iteration, those processes perform I/O simultaneously, creating a massive number of concurrent I/O operations that is proportional to the increasing number of client processes. While the computational power of high-performance computing systems increases in Moore’s law, innovation in I/O systems does not make progress at the same rate. This fact results in the new scalability challenges under massive parallelization within current storage technologies: I/O jitter, poor I/O throughput in concurrency, etc. Several approaches have been proposed to address those new challenges. Nawad et al. [80], introduced a scalable I/O forwarding framework for HPC systems. Dorier et al., introduced Damaris [81] (Dedicated Adaptive Module for Application’s Resources Inline Steering) that leverages dedicated cores for I/O services. The common point of5.2 – Storage challenges in data-intensive HPC 43 both those approaches was to reduce file system traffic under massive parallelization by aggregating, rescheduling, and caching I/O requests. Basically, the dedicated I/O nodes, or dedicated I/O cores take responsibility of performing I/O on behalf of the compute nodes, thus consequently reducing the number of concurrent I/O requests to the underlying storage systems. CHALLENGE: How to deal with massive parallelization? I/O atomicity. For concurrent I/O operations, atomicity semantics defines the outcome of overlapping regions in the file that are simultaneously read/written by multiple processes. Without atomic guarantees, the results state of the non-contiguous regions accessed by multiple processes may be inconsistent: the data of some contiguous parts may come from one process whereas the data of some other parts may come from another processes. When executing HPC scientific applications, guaranteeing atomicity of I/O operations is a crucial issue because those applications often perform a large number of overlapping I/O operations. Un-defined data at any intermediate iteration step, means inaccurate input for the sub-sequential steps, creating inaccurate final results. This consequence is undesired especially not only for the critical role of those applications but also for the expensive of rerunning the entire calculation process in the HPC environments. The current approaches to implement I/O atomicity do not scale. Either storage systems compromise atomicity to get better performance, either they rely on locking mechanism to ensure atomicity while performing poorly at large-scale. One question should be addressed: “What is the novel approach to guarantee I/O atomicity at minimal cost?” CHALLENGE: How to guarantee I/O atomicity at minimal cost? Array-oriented data organization model. Many established storage solutions such as parallel file systems and database management systems strive to achieve highperformance at large scale. However, one major difficulty is to achieve performance scalability of data accesses under concurrency. One limitation comes from the fact that most existing solutions expose data access models (e.g., file systems, structured databases) that are too general and do not exactly match the natural requirements of the application. This forces the application developer to either adapt to the exposed data access model or to use an intermediate layer that performs a translation. In either case, this mismatch leads to suboptimal data management: as noted in [51], the one-storage-solution-fits-all-needs has reached its limits. The situation described above highlights an increasing need to specialize the I/O stack to match the requirements of different types of applications. In scientific computing, of particular interest is a large class of applications that represent and manipulate data as huge multi-dimensional arrays [77]. Such applications typically consist of a large number of distributed workers that concurrently process subdomains of those arrays. In this context, an array-oriented data model will potentially help sustaining a high throughput for such parallel array processing.CHALLENGE: How to design storage systems optimized for parallel array processing? 5.3 Summary In this chapter, we have presented an overview of data-intensive HPC and argued that the data-management systems required in data-intensive HPC are one type of Big Data management. Indeed, those applications process “Big Volume” of data that are growing towards Exabyte scale. To be able to design scalable storage systems for data-intensive HPC, we studied the current parallel I/O frameworks and pointed out some challenges that need to be investigated. Our solutions to those challenges are then presented in the next chapters.45 Chapter 6 Providing efficient support for MPI-I/O atomicity based on versioning Contents 6.1 Problem description . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46 6.2 System architecture . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47 6.2.1 Design principles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47 6.2.2 A non-contiguous, versioning-oriented access interface . . . . . . . . 49 6.3 Implementation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50 6.3.1 Adding support for MPI-atomicity . . . . . . . . . . . . . . . . . . . . 51 6.3.2 Leveraging the versioning-oriented interface at the level of the MPII/O layer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 54 6.4 Evaluation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 54 6.4.1 Platform description . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55 6.4.2 Increasing number of non-contiguous regions . . . . . . . . . . . . . 56 6.4.3 Scalability under concurrency: our approach vs. locking-based . . . 56 6.4.4 MPI-tile-IO benchmark results . . . . . . . . . . . . . . . . . . . . . . 58 6.5 Positioning of the contribution with respect to related work . . . . . . . . 60 6.6 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61 This chapter is mainly extracted from the paper: Efficient support for MPIIO atomicity based on versioning. Tran V.-T., Nicolae B., Antoniu G., Bougé L. In Proceedings of the 11th IEEE/ACM International Symposium on Cluster, Cloud, and Grid Computing (CCGrid 2011), 514 - 523, Newport Beach, May 2011.46 Chapter 6 – Providing efficient support for MPI-I/O atomicity based on versioning W E consider the challenge of building data-management systems that meet an important requirement of today’s data-intensive HPC applications: to provide a high I/O throughput while supporting highly concurrent data accesses. In this context, many applications rely on MPI-I/O and require atomic, non-contiguous I/O operations that concurrently access shared data of the underlying storage systems. Because many widely used storage back-ends for HPC systems such as Lustre [82] and GPFS [83] strictly enforce the POSIX [46] access model, they do not support atomic, noncontiguous I/O operations. As a result, ROMIO [76], the MPI-I/O implementation currently in circulation is forced to implement this lacking features at MPI-I/O level through lockingbase schemes: writes simply lock the smallest contiguous region of the file that covers all non-contiguous regions that need to be written. Under a high degree of concurrency, such an approach is inefficient and becomes a major source of bottleneck. In this chapter, we address this shortcoming of existing approaches by optimizing the storage back-end specifically for the access pattern mentioned above. We propose a novel versioning-based scheme that offers better isolation and avoids the need to perform expensive synchronization. The key idea is to use multiple snapshots of the same data. Those snapshots offer a consistent view of the globally shared file, thanks to an efficient ordering and resolution of overlapping at metadata level. This enables high throughput under concurrency, while guaranteeing atomicity. The contributions in this chapter are summarized as follows. • We introduce a set of generic design principles that leverage versioning techniques and data striping to build a storage back-end that explicitly optimizes for non-contiguous, non-conflicting, overlapped I/O accesses under MPI atomicity guarantees. • We describe a prototype built along this idea, based on BlobSeer, a versioning-enabled, concurrency-optimized data management service that was integrated with ROMIO. • We report on a series of experiments performed with custom as well as standard MPI-I/O benchmarks specifically written for the applications that we target and show improvements in aggregated throughput under concurrency between 3.5 to 10 times higher than what state-of-art locking-based approaches can deliver. 6.1 Problem description In a large class of scientific applications, especially large-scale simulations, input and output data represents huge spatial domains made of billions of cells associated with a set of parameters (e.g., temperature, pressure, etc.). In order to process such vast amounts of data efficiently, the spatial domain is split into subdomains that are distributed and processed by a large number of compute elements, typically MPI processes. The computations performed on the subdomains are not completely independent: typically, the value of a cell throughout the simulation depends on the state of the neighboring cells. Thus, cells at the border of subdomains (called “ghost cells”) are shared by multiple MPI processes. In order to avoid repeated exchanges of border cells between MPI processes during the simulation, a large class of applications [77, 78, 84, 85] partition the spatial domain in such way that the resulting subdomains overlap at their borders. Figure 6.1(a) depicts an6.2 – System architecture 47 example of a 2D space partitioned in 3 × 3 overlapped subdomains, each being handled by one of processes P1, . . . , P9. At each iteration, the MPI processes typically dump their subdomains in parallel into a globally shared file, which is then later used to interpret and/or visualize the results. Since the spatial domain is a multi-dimensional structure that is stored as a single, flat sequence of bytes, the data corresponding to each subdomain maps to a set of non-contiguous regions within the file. This in turn translates to a write-intensive access pattern where the MPI processes concurrently write a set of non-contiguous, overlapping regions in the same file. Ensuring a consistent output is not a trivial issue. If all processes independently write the non-contiguous regions of their subdomains in the file, this may lead to a situation where the overlapped regions are the result of an inconsistent interleaving. Such a case is depicted on Figure 6.1(b), where two MPI processes, P1 and P2, concurrently write their respective subdomains. Only two consistent states are possible, where all the non-contiguous regions of P1 and P2 are atomically written into the file. They only differ by the order in which this happened, which for most applications is not important in practice: both results are acceptable under the assumption that P1 and P2 are both able to compute consistent cell values for the overlapped region, such that the difference between them does not affect the global outcome of the simulation. In an effort to standardize these access patterns, the MPI 2.0 standard [76] defines a specialized I/O interface, MPI-I/O, that enables read and write primitives to accept complex data types as parameters. These data types can represent a whole set of non-contiguous regions rather than a single contiguous region, as is the case of the POSIX read/write primitives. Thus, the problem of obtaining a consistent output is equivalent to guaranteeing atomicity for the MPI-I/O primitives, which is referred to as MPI atomicity. More precisely, MPI atomicity guarantees that in concurrent, overlapping MPI I/O operations, the results of the overlapped regions shall contain data from only one of the MPI processes that participates in the I/O operations. Large-scale applications need to compute huge domains that are distributed among a large number of processes. Under these circumstances, guaranteeing MPI atomicity in a scalable fashion is difficult to achieve: there is a need to sustain a high data access throughput despite a growing number of concurrent processes. This chapter addresses precisely this problem, facilitating an efficient implementation of the MPI-I/O standard, which in turn benefits a large class of MPI applications. 6.2 System architecture 6.2.1 Design principles We propose a general approach to solve the issue of enabling a high throughput under concurrency for writes of non-contiguous, overlapped regions under MPI atomicity guarantees. This approach relies on three key design principles.48 Chapter 6 – Providing efficient support for MPI-I/O atomicity based on versioning (a) 2D array partitioning with overlapping at the border.           (b) An example of two concurrent overlapping writes. Figure 6.1: Problem description: partitioning of spatial domains into overlapped subdomains and the resulting I/O access patterns and consistency issues. Dedicated API at the level of the storage back-end Traditional approaches address the problem presented in Section 6.1 by implementing the MPI-I/O layer on top of the POSIX access interface which does not provide access to noncontiguous blocks. The rationale behind this is to be able to easily plug in various storage back-ends without the need to rewrite the MPI-I/O layer. However, this advantage comes at a high price: the MPI-I/O layer needs to guarantee MPI atomicity through the POSIX consistency model, which essentially implies the need to build complex locking schemes. Such approaches greatly limit the potential to introduce optimizations in our context, because the POSIX access model was not originally designed for non-contiguous access patterns. For this reason, we propose to extend the storage back-end with a data access interface that closely matches the MPI-I/O read/write primitives. Using this approach circumvents the need to translate to a different consistency model and enables designing a better concurrency control scheme. Data striping The need to process huge spatial domain leads to an increasing trend in data sizes. This increasing trend can be observed not only on the total amount of data, but also on the data sizes that need to be individually handled by each process. As a general rule, the computation-toI/O ratio is steadily decreasing, which means that the performance of the whole application depends more and more on the performance of the I/O. In this context, storing the input and output files in a centralized fashion clearly does not scale. Data striping is a well-known technique to address this issue: the file where the spatial domain is stored can be split into chunks that are distributed among multiple storage elements. Using a load-balancing allocation strategy that redirects write operations to different storage elements in a round-robin fashion enables the distribution of the I/O workload among the storage elements, which ultimately increases the overall throughput that can be achieved. Understanding Vertical Scalability of I/O Virtualization for MapReduce Workloads: Challenges and Opportunities Bogdan Nicolae To cite this version: Bogdan Nicolae. Understanding Vertical Scalability of I/O Virtualization for MapReduce Workloads: Challenges and Opportunities. BigDataCloud’13: 2nd Workshop on Big Data Management in Clouds, Aug 2013, Aachen, Germany. HAL Id: hal-00856877 https://hal.inria.fr/hal-00856877 Submitted on 2 Sep 2013 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.Understanding Vertical Scalability of I/O Virtualization for MapReduce Workloads: Challenges and Opportunities Bogdan Nicolae IBM Research, Dublin, Ireland bogdan.nicolae@ie.ibm.com Abstract. As the explosion of data sizes continues to push the limits of our abilities to efficiently store and process big data, next generation big data systems face multiple challenges. One such important challenge relates to the limited scalability of I/O, a determining factor in the overall performance of big data applications. Although paradigms like MapReduce have long been used to take advantage of local disks and avoid data movements over the network as much as possible, with increasing core count per node, local storage comes under increasing I/O pressure itself and prompts the need to equip nodes with multiple disks. However, given the rising need to virtualize large datacenters in order to provide a more flexible allocation and consolidation of physical resources (transforming them into public or private/hybrid clouds), the following questions arise: is it possible to take advantage of multiple local disks at virtual machine (VM) level in order to speed up big data analytics? If so, what are the best practices to achieve a high virtualized aggregated I/O throughput? This paper aims to answer these questions in the context of I/O intensive MapReduce workloads: it analyzes and characterizes their behavior under different virtualization scenarios in order to propose best practices for current approaches and speculate on future areas of improvement. 1 Introduction Big Data analytics has enabled unprecedented insight into scientific, social and business challenges. Major advances in almost all fields (i.e. meteorology, genomics, complex physics simulations, environmental research, social networking and dynamics, financial forecasting, etc.) were possible thanks to increasing volume and diversity of data gathered and archived form a variety of sources: sensors, experimental data, mobile devices, etc. Not surprisingly, the rapid rate at which data sizes are growing has prompted the need for bigger and faster systems / techniques capable to perform big data analytics efficiently at an increasingly larger scale. Today, clusters of tens of thousands of nodes are a common occurrence. However, advances that make such systems possible are not homogeneous: while adding more computational power was demonstrated feasible both in terms cost and scalability, the I/O abilities in terms of networking and storage are lagging behind. Given the data-intensive nature of big data workloads, I/O performance is a determining factor in the overall performance of the applications, thus becoming a critical focus area.An important technique to limit the impact of I/O bottlenecks is to avoid data movements as much as possible, which conserves network bandwidth and thus helps achieve horizontal scalability. Several big data paradigms were developed around this concept, with MapReduce [1] and its open-source implementation Hadoop [2] being widely adopted in both academia and industry. Two key design principles enable MapReduce to avoid data movements. First, it forces the users to think their application in an embarrassingly parallel fashion that transforms the input as much as possible into a digested form (map phase) over which an aggregation is performed (reduce phase). Thus, during the map phase no extra network traffic is generated due to synchronization. Second, it departs from the traditional model of decoupling storage from computation, taking advantage of local storage to schedule the computation close to the data if possible, which again avoids network traffic. Although avoiding data movements is a powerful concept that helps conserve network bandwidth, at the same time it shifts the burden of I/O on the local storage. With horizontal scalability increasingly difficult to achieve and attention turning to vertical approaches (i.e. more cores per node), the I/O pressure grows large enough to introduce the need for multiple local disks. Thanks to its embarrassingly parallel design and a streaming I/O model that favors adding new data over modifying old data, MapReduce can easily take advantage of multiple disks to achieve a high aggregated I/O throughput, which is a feature already implemented in Hadoop. As nodes become increasingly complex and expensive to build and maintain in large numbers, big data systems become prohibitively expensive for most users. In this context, IaaS cloud computing emerged as a key technology to enable users to rent computational resources on-demand, paying only for what they have used. Thanks to virtualization, any user can easily create a large virtual big data cluster with the click of a button. However, how to efficiently map virtual resources to physical resources is a difficult challenge, especially when considering the increasing size and complexity of the nodes. In particular, the problem of how to virtualize multiple local disks efficiently to achieve a high aggregated I/O throughput is not well understood yet it is a crucial step in enabling efficient big data analytics on IaaS clouds. This paper aims to understand the problem mentioned above. What makes it particularly challenging is the multitude of factors that play a role in the I/O virtualization overhead (i.e. how many VMs per node, how many virtual disks per VM, virtual disk placement, etc.) that need to be analyzed. This is further augmented by missing functionality in state-of-art cloud middleware to enable users to express placement constraints for virtual disks. We summarize our contributions as follows: – We introduce an experimental framework that emulates a cloud middleware and enables fine-grain control over the hypervisor in order to easily express mapping constrains for virtual disks. Using this approach, experimental setups can be easily defined and the experimental conditions can be tightly monitored and controlled. – We experiment with I/O intensive MapReduce workloads in several virtualization setups. In particular, we analyze how well the striping mechanism implemented in Hadoop scales when using a variable number of VMs per node, virtual disks per VM and different virtual disk placement strategies.Hypervisor manager Compute node VM image repository local qcow2 SSD vdisk3 vdisk2 vdisk1 root FS mapping strategy disk3 disk2 disk1 VM1 root FS vdisk1 vdisk2 vdisk3 mapping strategy disk4 disk5 disk6 VM2 start/stop VMs monitor monitor Fig. 1. Architecture of the experimental framework – Based on the results, we identify several potential areas of improvement and comment on the associated research opportunities. 2 Architecture To facilitate fine-grain control over the hypervisor and the mapping between virtual and physical resources, we constructed an experimental framework that emulates a typical cloud middleware yet is highly configurable. The simplified architecture of this framework is depicted in Figure 1. For better clarity, the building blocks that are of special interest are emphasized with a darker background. The VM image repository is the storage service responsible to hold the disk image templates for the root file systems of the VM instances. For the purpose of this work, we build our own custom template (based on Debian Sid) with a pre-installed Hadoop environment. All templates are read-only and serve as a base image for locally derived qcow2 [3] images, where the VM instances are allowed to write into their root filesystem. For better performance, the locally derived images are stored on a SSD. Note that Hadoop does not write to the root file-system directly, but uses a separate set of dedicated virtual disks attached to the VM instance. The hypervisor manager is responsible to control all compute nodes and prepare the hypervisors to launch the VM instances in the desired configuration. It can be con- figured to use a variable number of nodes with a variable number of VMs per node, as well as attach a variable number of virtual disks per VM, according to a mapping strategy. For the purpose of this work, we implemented two mapping strategies: (1) round robin, which spreads the virtual disks to as many physical disks as possible in order to avoid I/O contention; and (2) consolidated, which places as many virtual disks as possible on the same physical disk, thus minimizing the number of required physical disks. Once a configuration was established, the hypervisor manager calculates the number of cores and amount of RAM per VM instance, creates the virtual disks according to the strategy and spawns the VM instances and attaches the virtual disks to them. In a final step, it generates the necessary configuration files for the Hadoop deployment (in particular it, enables striping on the attached virtual disks) and then deploys the Hadoop cluster. Striping is enabled both for HDFS [4], the default storage layer of Hadoop, aswell as for the intermediate data that is generated by the mappers and that is used by the reducers as input. To enable detailed analysis of the results, a monitor is deployed on each VM instance to gather performance information at fine granularity (5 seconds). This information includes CPU, memory, networking and virtual disk utilization and is kept both in raw form and in an aggregated fashion that is representative of the whole cluster utilization. 3 Experimental analysis Using the experimental framework described in the previous section, we study in this section the behavior of I/O intensive MapReduce workloads under different virtualization scenarios, in order to understand what aspects play an important role with respect to performance and scalability. 3.1 Setup The platform used to run our experiments is a custom testbed consisting of 6 nodes, each equipped with 32 x86 64 cores with support for virtualization, 96 GB of RAM and several Gigabit Ethernet networking interfaces (one of which is used for the experiments). With respect to local storage, each node is equipped with 12 HDD disks (capacity per disk: 1 TB, measured I/O throughput per disk: 160 MB/s) and 2 SDD disks (capacity per disk: 256GB, measured I/O throughput per disk: 430 MB/s). The hypervisor running on all compute nodes is QEMU/KVM 1.2.0, while the operating system is a recent Ubuntu distribution. The base image used to deploy the VMs is a recent Debian Sid distribution, on top of which we installed Hadoop 2.0.4. All VM instances share the same base image but write locally into their own derived copyon-write image (using the qcow2 format) that is stored on one of the SSDs. All extra virtual disks attached to the VMs that are used by Hadoop in striping mode correspond to raw files (256GB) that are stored on the HDDs (according to the mapping strategies presented in Section 2). Each VM formats all its extra virtual disks at boot time using the ext4 file system. To maximize I/O performance, KVM is configured to run in paravirtualized mode using the virtio driver. 3.2 Methodology We create a series of scenarios that involve a variable number of VMs per node and a variable number of virtual disks per VM using a combination of round robin and consolidated virtual disk mapping strategies. In all of our experiments, the virtual Hadoop cluster leverages the physical resources of all 6 nodes. More specifically, we reserve 60 GB of RAM and 30 CPU cores for VMs on each node, leaving the rest to deal with jitter and virtualization overhead. These physical resources are leveraged in three configurations: 1 VM / node (using all reserved cores and RAM), 2 VMs / node (each of which is allocated 30 GB of RAM and 15 CPU cores) and 3 VMs / node (each of which is allocated 20 GB of RAM and 10 CPU cores).Thus, we create a virtual Hadoop cluster of 6, 12 and 18 VMs respectively. For each configuration, we vary the number of virtual disks attached to each VM from 1 up to 8. These disks are mapped to physical HDDs using a per-VM round robin policy, i.e. all VMs on the same node share the same physical disk for their vdisk1, another physical disk for their vdisk2, etc. The Hadoop deployment itself is performed using YARN. Each VM runs a HDFS datanode and a YARN nodemanager. As mentioned in Section 2, Hadoop is configured to stripe both its intermediate data and persistent data (i.e. data stored by HDFS). Furthermore, each nodemanager is configured to accept a number of parallel mappers that matches the number of cores allocated to the VM. One of the nodes is chosen as the master and runs the HDFS namespace manager and the YARN resourcemanager, in addition to the datanode and nodemanager. As a representative workload to perform our study on, we chose the sort benchmark, a standard MapReduce workload that is part of the Hadoop distribution. It consists of two phases. In the first phase, a predefined amount of random data is generated using randomwriter. This workload writes variable-sized key-value pairs (keys between 10-1000 bytes, values between 0-20000 bytes) directly into HDFS. The mappers do not emit any output and the reduce phase is not used. For the purpose of this work, we configured randomwriter to use a total of 180 mappers (i.e. the total number of cores available in the Hadoop cluster), each of which is writing 2GB. After the first phase is complete, all previously generated data is sorted. In this case, the mapper is the predefined IdentityMapper and the reducer is the predefined IdentityReducer, both of which just pass their inputs directly to the output. The sorting itself is achieved thanks to the shuffling that is performed by the MapReduce framework. To parallelize this process as much as possible, we configured sort to use a maximum of 180 reducers, which matches the number of mapper slots. Thanks to this minimalist setup in terms of data processing itself, sort is heavily data intensive and emphasizes the I/O part, which is the reason why we chose it. Each experiment consists in fixing the number of VMs per node and the number of virtual disks per VM, then running the sort benchmark to completion, while recording cluster-wide monitoring information (using the monitors presented in Section 2) and the completion times. 3.3 Results The completion times for the sort benchmark using a variable number of VMs per node and a variable number of disks per VM is depicted in Figure 2(a). As can be observed, in all three configurations, there is a sharp drop in completion time with an increasing number of virtual disks attached to the VMs. This fact confirms that I/O performance plays a crucial role in the overall application performance: when increasing the number of virtual disks from 1 to 8, a reduction in execution time of up to 70% is observable. Focusing on the single VM per node scenario, two main factors contribute to the results mentioned above. First, as can be observed in Figure 3(b), an increasing number of virtual disks dramatically lowers the overall I/O pressure in the Hadoop cluster: from an aggregated utilization that tops 100% in the case of 1 virtual disk, a drop to 0 500 1000 1500 2000 2500 3000 3500 4000 1 2 3 4 5 6 7 8 Completion time (s) # disks/instance 1 VM / node 2 VMs / node 3 VMs / node (a) Completion time 0 50 100 150 200 250 300 350 1 2 3 Total number of tasks Number of VMs per node remote-mappers-1vdisk remote-mappers-4vdisks remote-mappers-8vdisks reducers (b) Statistics on remote mappers and reducers Fig. 2. Performance results for the sort benchmark, using a variable number of VMs per node and a variable number of disks per VM a maximum of 50% and 25% is noticeable in the case of 4 and 8 disks respectively. Figure 3(a) reveals an interesting fact: a lower I/O pressure does not significantly affect the aggregated CPU utilization: all curves follow a similar pattern up to a point when the CPU utilization drops sharply for the rest of the execution: this is the point when the mappers have finished and only reducers are still running. Thus, I/O is the dominating factor during the reduce phase and it oversaturates the disk bandwidth in the case of 1 vdisk, leading to a longer execution time. This is also confirmed by Figure 3(b): using only 1 vdisk results in 100% disk utilization for a significant portion of the reduce phase. Second, according to Figure 2(b), a different distribution of map and reduce tasks is observable: increasing the number of virtual disks results in improved locality (less remote mappers, which means more data-local mappers) and thus better performance due to less data movement. However, considering the total number of mappers is around 2800, even for 1 vdisk there are less than 7% of remote mappers. Thus, we suspect the impact of improved locality on the overall application performance is small compared to the impact of lower I/O pressure due to more virtual disks. What scalability is concerned, there is a noticeable drop in the benefits of adding more virtual disks (i.e. 27% reduction from one to two virtual disks compared to 9% reduction from 6 to 8 virtual disks). This is understandable considering the lower overall I/O utilization and it leads to an important observation: while there is considerable performance improvement due to lower I/O pressure, Hadoop striping does not fully leverage the aggregated I/O bandwidth offered by multiple local virtual disks. Counter-intuitively, adding more VMs per node also benefits overall performance, despite more virtualization overhead and more data movements due to VMs on the same node being isolated from each other. As can be observed in Figure 2(a), the completion times for 2 and 3 VMs per node follow the same shape as the curve corresponding to 1 VM per node, however they are smaller by a significant near-constant factor. To explain this effect, notice the better overall CPU utilization in Figure 3(c) and Figure 3(e): from an average of 65% in the case of 1 VM per node, it has risen to 80% and 90% for 2 and 3 VMs respectively, leading to a shorter map phase. Thus, we 0 20 40 60 80 100 0 500 1000 1500 2000 2500 3000 3500 4000 4500 Aggregated utilization [% of max] Time (s) 1 vdisk 4 vdisks 8 vdisks (a) Average CPU utilization for all nodes with 1 VM / node 0 20 40 60 80 100 0 500 1000 1500 2000 2500 3000 3500 4000 4500 Aggregated utilization [% of max] Time (s) 1 vdisk 4 vdisks 8 vdisks (b) Average disk utilization for all nodes with 1 VM / node 0 20 40 60 80 100 0 500 1000 1500 2000 2500 3000 Aggregated utilization [% of max] Time (s) 1 vdisk 4 vdisks 8 vdisks (c) Average CPU utilization for all nodes with 2 VMs / node 0 20 40 60 80 100 0 500 1000 1500 2000 2500 3000 Aggregated utilization [% of max] Time (s) 1 vdisk 4 vdisks 8 vdisks (d) Average disk utilization for all nodes with 2 VMs / node 0 20 40 60 80 100 0 500 1000 1500 2000 2500 3000 3500 Aggregated utilization [% of max] Time (s) 1 vdisk 4 vdisks 8 vdisks (e) Average CPU utilization for all nodes with 3 VMs / node 0 20 40 60 80 100 0 500 1000 1500 2000 2500 3000 3500 Aggregated utilization [% of max] Time (s) 1 vdisk 4 vdisks 8 vdisks (f) Average disk utilization for all nodes with 3 VMs / node Fig. 3. Aggregated statistics for overall CPU and disk utilization with a variable number of VMs per node and virtual disks per VMconclude that the load balancing implemented in Hadoop is currently tuned towards horizontal scalability rather than vertical scalability. This tendency is also confirmed by a shorter reduce phase, for which the explanation is found in Figure 2(b): as the size of the Hadoop cluster grows, more reducers are used, despite an overall constant number of reducer slots being available in all configurations. Nevertheless, when the number of VMs that share the same node increases, so does the virtualization overhead, limiting the potential to exploit horizontal scalability simply by adding more VMs per node. This trade-off can be observed from the completion times of 2 and 3 VMs per node, which are very close to each other (Figure 2(a)). Higher I/O pressure slightly tips the balance in favor of 2 VMs per node for few virtual disks per instance, while the opposite holds for higher number of virtual disks per instance. 4 Related work Several state-of-art cloud middleware [5] (such as OpenStack [6]) offer dedicated storage solutions that aggregate block storage available on compute nodes to form distributed repositories. However, there is no specific feature or API to control how virtual disks are mapped to physical disks. Thus, we felt the need to contribute with our own experimental framework. Extensive related work has been undertaken in the area of MapReduce workload characterization. Some works report low resource utilization [7] and suggest potential energy savings by consolidating workloads to fewer nodes. With respect to I/O, Ren et al. [8] conclude that improving data locality has little potential to improve I/O performance, which is also confirmed by our findings. They suggest in-memory storage, potentially in form of a DSM (distributed shared memory) as an alternative to disk storage. Other studies focus particularly on HDFS [9, 10]. Unlike our approach, the focus is on HDFS utilization (i.e. metadata, file access patterns create, read, write, delete, etc.) and does not involve intermediate data. Furthermore, instead of mixed I/O from multiple workloads, we analyze single workloads in isolation, in order to understand potential correlations. Our own previous work [11] explores how to replace HDFS with a new storage layer based on BlobSeer [12], a versioning-based distributed storage system specifi- cally designed for high throughput under concurrency. This previous work focuses on horizontal scalability and does not involve virtualization issues. Several efforts have acknowledged the need to optimize MapReduce I/O at node level. Themis [13] implements the MapReduce paradigm using different design decisions than Hadoop. In particular, it introduces a centralized per-node disk scheduler that batches together records produced by different mappers in order to minimize the number of I/O operations. Ibrahim et al. [14] focus on improving I/O virtualization by means of smart coupling of the disk schedulers used at host and guest level that adapts to the workload. Unlike our case, the focus in these efforts is on how to optimize I/O for single disks rather than how to efficiently aggregate the bandwidth of multiple disks. To our best knowledge, we are the fist to explore the problem of efficient virtualization of multiple local disks for data-intensive MapReduce workloads.5 Conclusions With increasing data sizes, big data analytics becomes increasingly challenging. In a quest to keep up with scalability, paradigms such as MapReduce were specifically designed to decouple tasks and improve horizontal scalability of big data systems. However, with horizontal scalability increasingly difficult to achieve, vertical scalability has recently gained increasing attention. Although adding more cores per node is a common occurrence, adding more disks per node to improve local I/O capabilities is not. Since big data applications are I/O intensive, doing so is highly desirable in order to remain scalable. Furthermore, given the tendency to virtualize datacenters in order to improve utilization and/or sell cloud computing services, the problem of how to efficiently virtualize multiple local disks for big data analytics is becoming crucial. In this work we addressed the above problem. Given that this direction is still emerging, current cloud computing middleware is lacking features to guarantee effi- cient placement of virtual disks. Thus, our first contribution was to build an experimental framework that is able provide control over virtual disk placement, either spreading them over multiple physical disks in order to improve aggregated I/O or consolidating them on few physical disks. Based on this experimental framework, we analyzed a data-intensive Hadoop workload in various virtualization settings. First of all, we found that Hadoop workloads can significantly benefit from striping to multiple virtual disks, with reductions in overall completion time of up to 70% when aggregating the I/O of 8 disks compared to a single disk. However, our findings also show that Hadoop is better designed for horizontal rather than vertical scalability: its striping ability makes increasingly less use of the overall aggregated I/O bandwidth with increasing number of virtual disks. Furthermore, its load balancing ability increases with increasing number of VMs, despite sharing the same physical resources. This presents an interesting trade-off: on one side, increasing the number of VMs per node and/or the number of virtual disks per VM increases the virtualization overhead, but on the other hand it enables Hadoop to leverage the infrastructure better. Thanks to these findings, we propose two interesting directions as future work. The first direction deals with how to improve Hadoop itself in order to enable it to leverage multiple virtual disks efficiently, both at the level of intermediate data and persistent data that needs to be saved in HDFS. In this context, the relationship to virtualization would be interesting to explore: would Hadoop benefit from being virtualization-aware? If so, what optimizations would be possible? Furthermore, does this go both ways (in other words, are there any hints it can give to the virtualization layer so that the latter can perform specific optimizations)? Second, since Hadoop striping does not fully leverage the aggregated local I/O bandwidth to its full potential, another interesting direction to explore is whether presenting a single virtual disk to the VM and doing striping transparently in the background at hypervisor level can make better use of the aggregated bandwidth. In this context, we propose the concept of bandwidth-elastic virtual disk: a virtual disk that stripes to more physical disks under high I/O pressure and consolidates to less physical disks when the I/O pressure is lower, thus improving I/O resource utilization and en-abling more efficient multi-tenancy and lower operational costs (e.g. saving energy by powering off disks). References 1. Dean, J., Ghemawat, S.: MapReduce: simplified data processing on large clusters. Communications of the ACM 51(1) (2008) 107–113 2. White, T.: Hadoop: The Definitive Guide. O’Reilly Media, Inc. (2009) 3. Gagn´e, M.: Cooking with Linux—still searching for the ultimate Linux distro? Linux J. 2007(161) (2007) 9 4. Shvachko, K., Huang, H., Radia, S., Chansler, R.: The Hadoop distributed file system. In: MSST ’10: The 26th Symposium on Massive Storage Systems and Technologies. (2010) 5. Zhang, Z., Wu, C., Cheung, D.W.: A survey on cloud interoperability: taxonomies, standards, and practice. SIGMETRICS Perform. Eval. Rev. 40(4) (April 2013) 13–22 6. Baset, S.A.: Open source cloud technologies. In: SoCC ’12: Proceedings of the 3rd ACM Symposium on Cloud Computing, New York, NY, USA, ACM (2012) 28:1–28:2 7. Kavulya, S., Tan, J., Gandhi, R., Narasimhan, P.: An analysis of traces from a production mapreduce cluster. In: CCGRID ’10: Proceedings of the 10th IEEE/ACM International Conference on Cluster, Cloud and Grid Computing, IEEE Computer Society (2010) 94–103 8. Ren, Z., Xu, X., Wan, J., Shi, W., Zhou, M.: Workload characterization on a production hadoop cluster: A case study on taobao. In: IISWC ’12: Proceedings of the 2012 IEEE International Symposium on Workload Characterization, San Diego, USA, IEEE Computer Society (2012) 3–13 9. Abad, C.L., Roberts, N., Lu, Y., Campbell, R.H.: A storage-centric analysis of mapreduce workloads: File popularity, temporal locality and arrival patterns. In: IISWC ’12 Proceedings of the 2012 IEEE International Symposium on Workload Characterization, San Diego, USA (2012) 100–109 10. Abad, C.L., Luu, H., Roberts, N., Lee, K., Lu, Y., Campbell, R.H.: Metadata traces and workload models for evaluating big storage systems. In: UCC ’12: Proceedings of the 5hth International Conference on Utility and Cloud Computing, Chicago, USA, IEEE Computer Society (2012) 125–132 11. Nicolae, B., Moise, D., Antoniu, G., Boug´e, L., Dorier, M.: Blobseer: Bringing high throughput under heavy concurrency to hadoop map/reduce applications. In: IPDPS ’10: Proc. 24th International Parallel and Distributed Processing Symposium, Atlanta, USA (2010) 1–12 12. Nicolae, B., Antoniu, G., Boug´e, L., Moise, D., Carpen-Amarie, A.: Blobseer: Nextgeneration data management for large scale infrastructures. J. Parallel Distrib. Comput. 71 (2011) 169–184 13. Rasmussen, A., Lam, V.T., Conley, M., Porter, G., Kapoor, R., Vahdat, A.: Themis: an i/oefficient mapreduce. In: SoCC ’12: Proceedings of the Third ACM Symposium on Cloud Computing, San Jose, USA, ACM (2012) 13:1–13:14 14. Ibrahim, S., Jin, H., Lu, L., He, B., Wu, S.: Adaptive disk i/o scheduling for mapreduce in virtualized environment. In: ICPP ’11: The 2011 International Conference on Parallel Processing, Taipei, Taiwan (2011) 335–344 Semantic HMC for Big Data Analysis Thomas Hassan, Rafael Peixoto, Christophe Cruz, Aurlie Bertaux, Nuno Silva To cite this version: Thomas Hassan, Rafael Peixoto, Christophe Cruz, Aurlie Bertaux, Nuno Silva. Semantic HMC for Big Data Analysis. 2014 IEEE International Conference on Big Data, Oct 2014, Washington, United States. . HAL Id: hal-01089741 https://hal.archives-ouvertes.fr/hal-01089741 Submitted on 2 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.Semantic HMC for Big Data Analysis Thomas Hassan Universit de Bourgogne Dijon, France thomas.hassan@checksem.fr Rafael Peixoto Polytechnic of Porto Porto, Portugal rafpp@isep.ipp.pt Christophe Cruz Universit de Bourgogne Dijon, France christophe.cruz@u-bourgogne.fr Aurlie Bertaux Universit de Bourgogne Dijon, France aurelie.bertaux@iut-dijon.u-bourgogne.fr Nuno Silva Polytechnic of Porto Porto, Portugal nps@isep.ipp.pt Abstract—Analyzing Big Data can help corporations to improve their efficiency. In this work we present a new vision to derive Value from Big Data using a Semantic Hierarchical Multi-label Classification called Semantic HMC based in a nonsupervised Ontology learning process. We also proposea Semantic HMC process, using scalable Machine-Learning techniques and Rule-based reasoning. Index Terms—classification; multi-classify; Big-Data; ontology; semantic technologies; machine learning I. INTRODUCTION Nowadays, discovering knowledge and insights over web data is a major task for most corporations to increase their competitiveness. Determining the value of information relative to a particular customer is a complex task addressed by the business intelligence/data-mining field [1]. In the context of Big Data, this task is even more challenging, due to its characteristics. An increasing number of Vs has been used to characterize Big Data [2], [3]: Volume, Velocity, Variety and Value. Volume concerns the large amount of data that is generated and stored through the years by social media, sensor data, etc[2]. Velocity concerns both to the production and the process to meet a demand because Big Data is not only a huge volume of data but it must be processed quickly. Variety relates to the various types of data composing the Big Data. These types include semi-structured and unstructured data representing 90% of his content [4] such as audio, video, webpage, and text, as well as traditional structured data, etc. Value measures how valuable the information to a Big Data consumer is. Value is the most important feature of Big Data and his raison dłtre because if data dont have value then is useless. An IDC report [5] proposes the value extraction from very large volumes of a wide variety of data, by enabling the high-velocity capture, discovery, and/or analysis. Sheth [6] proposes deriving Value via harnessing the challenges posed by Volume, Variety, and Velocity using semantic techniques and technologies. This requires organized ways to harness and overcome the four V-challenges by using metadata and employ semantics and intelligent processing. Our aim is to extract Value from Big Data by harnessing a huge Volume and Variety of data that change constantly (Velocity) by using a novel unsupervised ontology learning process based on HMC called Semantic HMC. Hierarchical Multi-Label Classification (HMC) is the combination of Multi-Label classification and Hierarchical classification [7]. In HMC, the items can be assigned to different hierarchical paths and simultaneously may belong to different class labels in the same hierarchical level [7]. The ontology [8] plays a key role in defining terms and meanings used to represent the knowledge, reducing the gap between the users and the HMC process. This paper does not aim to improve the state of the art in multi-classification, nor the automatic hierarchy construction. Instead it proposes a scalable process to semantically learn the ontology by adopting scalable machine learning processes and Rule-based reasoning [9] to classify the data items and therefore extract value from Big Data. The contributions of this work are twofold: • Scalable ontology learning process based on HMC (Semantic HMC). • Big Data Analysis using a Semantic HMC. The rest of the paper covers three sections. The second section describes how to use the Semantic HMC to extract value from Big Data. The third section describes the Semantic HMC process proposal. Finally, the last section draws conclusions and suggests further research. II. USING SEMANTIC HMC TO DERIVE VALUE IN BIG DATA CONTEXT Our approach is to exploit value from very large volumes of data that are in constant generation using a Semantic HMC approach. The Semantic HMC process learns the Tbox (Taxonomy and Rules) part of the ontology from the huge Volume and Variety of initial data. Once this learning phase is finished, the classification system incrementally learns the Tbox from the new incoming items (Abox) to provide and respond to the Velocity (and the others V) dimension(s). The result of this Semantic HMC process is a rich ontology with the items (instances) classified according to the learned concept hierarchy (i.e. the taxonomy of the ontology). Corporations recurrently use concept hierarchies as taxonomies to represent their valuable information [10]. Our vision is to use the concept hierarchies from corporations to validate the valueBig Data Semantic HMC Ontology Corporation Taxonomies Similarity Fig. 1. Value extraction for corporations of the learned ontology for a specific corporation (Fig. 1). Higher similarity between the concept hierarchy of the learned ontology and the concept hierarchy used by a corporation suggest better alignment between the HMC results and the corporations knowledge and goals. Consequently, data items classified as valuable concepts ultimately present better value to the corporation than those not matching the corporations concepts. III. SEMANTIC HMC PROCESS Our Semantic HMC process is generic for a large Variety of unstructured data items (e.g. text, images) and scalable for a large Volume of data. The process is unsupervised such that no previously labeled/classified examples or rules to relate the data items with the labels exist. The label (i.e. concepts) hierarchy and the rules are automatically learned from the data through scalable Machine Learning techniques. To infer the most specific concepts for each data item and all subsuming concepts we use rule-based reasoning that exhaustively applies a set of rules to a set of triples to infer conclusions [9], i.e. the items classifications. This Rule-based reasoning approach allows the parallelization and distribution of work by large clusters of inexpensive machines through Big Data technologies as Map-reduce [11]. Web Scale Reasoners [12] currently uses Rule-Based reasoning to reach high scalability by load parallelization and distribution, thus addressing the Velocity and Volume dimensions of Big Data. This proposed process consists of 5 individually scalable steps (Fig. 2) matching the requirements of Big Data processing: • Indexation parses and creates an index of data items. • Vectorization calculates term-frequency vectors of the indexed items. • Hierarchization creates a concept hierarchy based on term frequency. • Resolution creates the reasoning rules to relate data items with the hierarchy concepts based on term frequency. • Realization first populates the ontology with items and then for each item determines the most specific hierarchy concept and all his subsuming concepts. 1.Indexation Ontology 2.Vectorization Itens Hierarchy 3.Hierarchization Rules Classified Items 4.Resolution 5.Realization Unclassified Items Indexed Collection Hierarchy Concepts Term-Frequency vectors Fig. 2. Semantic HMC Process A. Indexation The indexation step parses and index data items. As one of our main focus points is the scalability of the architecture, the indexation is a mandatory step. Each item type has its specific parser to efficiently retrieve useful information for the other steps reducing the Limited Context Analysis problem. The Limited Content Analysis problem [13][14] is defined by the difficulty in extracting reliable automated information from various content (e.g. text, images, sound, etc.), which can greatly reduce the quality of the classifications. By reducing the Limited Content Analysis we improve the Semantic HMC capability to handle more Variety of data. B. Vectorization The vectorization step vectorizes the terms in the indexed items by calculating two types of term frequency vectors : • Term frequency in each item using the frequency of a term in an item measured by TF-IDF. TF-IDF uses the frequency of a term in an item (TF) and the inverse number of items in which the term appears (IDF) [15]. • Term frequency in all items using the appearing frequency of a term in all documents [15]. The following steps use the term vectors calculated in this step to learn the concepts Hierarchy and the Rules. C. Hierarchization The hierarchization step will select relevant terms as relevant concepts and also will generate the broader-narrower relations between these concepts. To select the concepts in the hierarchy, a quality measure must be used. There are several methods for creating hierarchical relations between concepts including [16], [17]: • Hierarchical clustering that starts with one cluster and progressively merges clusters that are closest.• Subsumption methods that construct the concept broadernarrower relations based on the co-occurrence of concepts. Any of these methods can be used to create the hierarchical relations. The advantages and drawbacks of each method is deeply studied in [16]. D. Resolution The resolution process will create the ontology rules used to relate the hierarchy concepts and the items using the term-frequency vectors. The rules creation process will use thresholds as proposed in [18] to select the most relevant terms for each hierarchy concept that will be used in the rules. The main difference is that instead of translating the rules into logical constraints of an ontology captured in Description Logic, these rules will be translated into rules in the Semantic Web Rule Language (SWRL). The main interest in using SWRL rules is to reduce the reasoning effort, thus improving the scalability and performance of the system. We aim to use a huge amount of simple SWRL rules that will be applied to the ontology in order to classify items. E. Realization The realization phase will populate the learned concept hierarchy with data items. First the ontology is populated with new items to label in an assertion level (Abox). To do the classification/labeling of the items, the SWRL Rules generated in the Resolution step are used. Then a Rule-Based inference engine will use the SWRL rules and the hierarchy to infer the most specific concepts for each data item and all subsuming concepts. This leads to a multi-label classification of the documents based in a hierarchy of labels (Hierarchical Multi-label Classification). IV. CONCLUSION In this paper we present our vision to extract value from Big Data using a Semantic HMC process and propose a scalable five-step architecture to automatically classify unstructured items. We use machine learning to learn an ontology with SWRL rules to automatically classify items of Big Data. The Semantic HMC process prototype is under development and we expect to show the implementation and results in further work. Our current work consists in evaluating the resulting ontology, considering three different aspects: the process scalability (performance), the quality of the hierarchy, and the quality of the classification process (i.e. concept tagging of items). ACKNOWLEDGMENT This project is founded by the company Actualis SARL, the French agency ANRT and through the Portuguese COMPETE Program under the project AAL4ALL (QREN13852). REFERENCES [1] I. H. Witten and E. Frank, Data Mining: Practical machine learning tools and techniques. Morgan Kaufmann, 2005. [2] M. Chen, S. Mao, and Y. Liu, “Big Data: A Survey,” Mobile Networks and Applications, pp. 171–209, 2014. [3] P. Hitzler and K. Janowicz, “Linked data, big data, and the 4th paradigm,” Semantic Web, vol. 4, pp. 233–235, 2013. [4] A. Syed, K. Gillela, and C. Venugopal, “The Future Revolution on Big Data,” Future, vol. 2, pp. 2446–2451, 2013. [5] J. Gantz and D. Reinsel, “Extracting value from chaos,” IDC iview, pp. 1–12, 2011. [6] A. Sheth, “Transforming Big Data into Smart Data,” 2014 IEEE 30th International Conference on Data Engineering, pp. 2–2, 2014. [7] W. Bi and J. Kwok, “Multi-label classification on tree-and DAGstructured hierarchies,” Yeast, pp. 1–8, 2011. [8] R. Studer, V. R. Benjamins, and D. Fensel, “Knowledge engineering: Principles and methods,” Data & Knowledge Engineering, pp. 161–197, 1998. [9] J. Urbani, F. van Harmelen, S. Schlobach, and H. Bal, “QueryPIE: Backward Reasoning for OWL Horst over Very Large Knowledge Bases,” in ISWC’11. Springer-Verlag, 2011, pp. 730–745. [10] P. Lambe, Organising knowledge: taxonomies, knowledge and organisational effectiveness. Elsevier, 2014. [11] J. Dean and S. Ghemawat, “MapReduce : Simplified Data Processing on Large Clusters,” Communications of the ACM, pp. 1–13, 2008. [12] J. Urbani, “Three Laws Learned from Web-scale Reasoning,” in 2013 AAAI Fall Symposium Series, 2013. [13] P. Lops, M. de Gemmis, and G. Semeraro, “Content-based recommender systems: State of the art and trends,” in Recommender Systems Handbook. Springer, 2011, pp. 73–105. [14] J. Bobadilla, F. Ortega, A. Hernando, and A. Gutierrez, “Recommender ´ systems survey,” Knowledge-Based Systems, pp. 109–132, 2013. [15] G. Salton and C. Buckley, “Term-weighting approaches in automatic text retrieval,” Information processing & management, pp. 513—-523, 1988. [16] J. de Knijff, F. Frasincar, and F. Hogenboom, “Domain taxonomy learning from text: The subsumption method versus hierarchical clustering,” Data & Knowledge Engineering, pp. 54–69, 2013. [17] K. Meijer, F. Frasincar, and F. Hogenboom, “A Semantic Approach for Extracting Domain Taxonomies from Text,” Decision Support Systems, 2014. [18] D. Werner, N. Silva, and C. Cruz, “Using DL-Reasoner for Hierarchical Multilabel Classification applied to Economical e-News,” in Science and Information Conference, 2014, p. 8. Mod´elisation et impl´ementation de parall´elisme implicite pour les simulations scientifiques bas´ees sur des maillages H´el`ene Coullon To cite this version: H´el`ene Coullon. Mod´elisation et impl´ementation de parall´elisme implicite pour les simulations scientifiques bas´ees sur des maillages. Distributed, Parallel, and Cluster Computing. Universit´e d’Orl´eans, 2014. French. 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