Programmation Parallèle Haute Performance PDF

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

INF7235

Programmation parallèle haute performance


Notes de cours

Guy Tremblay

Hiver 2017
Table des matières

I Introduction à la concurrence et au parallélisme 6


1 Introduction : Programmation concurrente et parallèle ⇒ transparents 7

2 Aperçu des architectures parallèles ⇒ transparents 8

3 Concepts de base de la concurrence et du parallélisme 9


3.1 Processus vs. thread . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
3.2 Concurrence vs. parallélisme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
3.3 Contrôle de la concurrence . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
3.4 Algorithmes parallèles et graphes de dépendances des tâches . . . . . . . . . . . . . 36
3.5 Indépendance entre threads . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
3.6 Concurrence vs. parallélisme (bis) : Exemples Ruby/MRI vs. JRuby . . . . . . . . 58
3.7 Exercice : Interactions entre threads et tableaux dynamiques . . . . . . . . . . . . 64

II Introduction au langage Ruby 65


4 Introduction au langage Ruby par des exemples 66
4.1 Introduction : Pourquoi Ruby? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
4.2 Compilation et exécution de programmes Ruby . . . . . . . . . . . . . . . . . . . . 73
4.3 irb : Le shell interactif Ruby . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 74
4.4 Tableaux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77
4.5 Chaînes de caractères . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 82
4.6 Symboles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
4.7 Hashes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 90
4.8 Expressions booléennes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 94
4.9 Définitions et appels de méthodes . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99
4.10 Structures de contrôle . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102
4.11 Paramètres des méthodes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108
4.12 Définitions de classes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 113
4.13 Lambda-expressions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 116
4.14 Blocs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 125
4.15 Portée des variables . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 130
4.16 Modules . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 134
4.17 Modules Enumerable et Comparable . . . . . . . . . . . . . . . . . . . . . . . . . . 139
4.18 Itérateurs définis par le programmeur . . . . . . . . . . . . . . . . . . . . . . . . . 152
4.19 Expressions régulières et pattern matching . . . . . . . . . . . . . . . . . . . . . . . 157

1
TABLE DES MATIÈRES 2

4.20 Interactions avec l’environnement . . . . . . . . . . . . . . . . . . . . . . . . . . . . 169


4.21 Traitement des exceptions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 190
4.22 Autres éléments de Ruby . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 197
4.A Installation de Ruby sur votre machine . . . . . . . . . . . . . . . . . . . . . . . . . 206
4.B Le cadre de tests unitaires MiniTest . . . . . . . . . . . . . . . . . . . . . . . . . . 208
4.C Règles de style Ruby . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 223
4.D Méthodes attr_reader et attr_writer . . . . . . . . . . . . . . . . . . . . . . . . 229
4.E Interprétation vs. compilation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 231

III Programmation parallèle en mémoire partagée


avec Ruby/PRuby 232
5 Patrons de programmation parallèle avec PRuby 233
5.1 La bibliothèque PRuby . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 233
5.2 Parallélisme fork–join : pcall et future . . . . . . . . . . . . . . . . . . . . . . . . 234
5.3 Parallélisme de boucles : peach et peach_index . . . . . . . . . . . . . . . . . . . 255
5.4 Parallélisme de données et approche de style fonctionnel : pmap et preduce . . . . 267
5.5 Parallélisme style «Coordonnateur/Travailleurs» : peach/pmap dynamique et TaskBag281
5.6 Parallélisme de flux de données avec filtres et pipelines : pipeline, pipeline_source
et pipeline_sink . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 291
5.A Sommaire : Comparaison de quelques approches pour la somme de deux tableaux . 312

IV Métriques de performance et patrons algorithmiques 316


6 Métriques de performance pour algorithmes et programmes parallèles 317
6.1 Introduction : le temps d’exécution suffit-il? . . . . . . . . . . . . . . . . . . . . . . 318
6.2 Temps d’exécution et profondeur . . . . . . . . . . . . . . . . . . . . . . . . . . . . 320
6.3 Coût, travail et optimalité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 323
6.4 Accélération et efficacité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 332
6.5 Dimensionnement (scalability) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 335
6.6 Coûts des communications . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 335
6.7 Sources des surcoûts d’exécution parallèle . . . . . . . . . . . . . . . . . . . . . . . 335
6.8 Lois d’Amdhal, de Gustafson–Barsis et fraction séquentielle expérimentale . . . . . 338
6.A Mesures de performances : Un exemple concret . . . . . . . . . . . . . . . . . . . . 349

7 Méthodologie pour la programmation parallèle et patrons d’algorithmes 355


7.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 355
7.2 Approche PCAM . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 356
7.3 Méthodologie de programmation parallèle avec threads inspirée de l’approche PCAM 357
7.4 Effet de la granularité sur les performances . . . . . . . . . . . . . . . . . . . . . . 361
7.5 Patrons d’algorithmes parallèles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 363

8 Exemples illustrant l’approche PCAM 381


8.1 Calcul de la distance d’édition entre deux chaines . . . . . . . . . . . . . . . . . . . 381
8.2 Résolution numérique de l’équation de diffusion de la chaleur dans un cylindre . . 393
TABLE DES MATIÈRES 3

V Programmation parallèle en mémoire partagée avec d’autres


langages 405
9 Programmation parallèle avec C++ et les Threading Building Blocks d’Intel 406
9.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 406
9.2 Quelques éléments de C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 409
9.3 Parallélisme de boucles : parallel_for . . . . . . . . . . . . . . . . . . . . . . . . 421
9.4 Réduction : parallel_reduce . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 430
9.5 Fonctionnement du parallel_for avec blocked_range et rôle du partitioner . . 440
9.6 Parallélisme de contrôle : parallel_invoke et task_group . . . . . . . . . . . . . 448
9.7 Tri parallèle : parallel_sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 451
9.8 Parallélisme de flux : pipeline . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 453
9.9 Mesures de performance . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 457
9.10 Approche fondée sur les tâches et ordonnancement par «vol de tâches» . . . . . . . 459
9.11 L’approche «Diviser-pour-régner» et la notion de Range . . . . . . . . . . . . . . . 462

10 Programmation parallèle avec OpenMP/C 469


10.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 469
10.2 Directives et opérations de base . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 472
10.3 Autres directives de synchronisation . . . . . . . . . . . . . . . . . . . . . . . . . . 479
10.4 Clauses de distribution du travail entre les threads dans les boucles . . . . . . . . . 481
10.5 Création dynamique de tâches . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 482
10.6 Exemples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 483
10.A Modèles avec fork/join ou threads explicites vs. implicites . . . . . . . . . . . . . . 489
10.B Exercices . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 496

11 Programmation parallèle de GPU avec OpenCL/C 498


11.0 Brefs rappels sur l’évolution des architectures parallèles . . . . . . . . . . . . . . . 499
11.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 504
11.2 Modèle d’exécution et de programmation d’OpenCL . . . . . . . . . . . . . . . . . 508
11.3 Exemple : Somme de deux tableaux . . . . . . . . . . . . . . . . . . . . . . . . . . 517
11.4 Exemple : Produit de deux matrices . . . . . . . . . . . . . . . . . . . . . . . . . . 531
11.5 Exemple : Sommation des éléments d’un tableau . . . . . . . . . . . . . . . . . . . 545
11.6 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 559

12 Programmation parallèle et concurrente avec Java 560


12.1 Lambda-expressions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 560
12.2 Classe Thread vs. interface Runnable . . . . . . . . . . . . . . . . . . . . . . . . . . 567
12.3 Exemples simples comparant la création des threads en MPD, PRuby et Java . . . 570
12.4 Fonction pour le calcul de π avec méthode Monte Carlo . . . . . . . . . . . . . . . 577
12.5 Exemples des principaux patrons de programmation pour la somme de deux tableaux584
12.6 Les objets comme moniteurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 595
12.7 Interruption d’un thread . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 601
12.8 Priorités des threads . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 602
12.9 Quelques autres méthodes de la classe Thread . . . . . . . . . . . . . . . . . . . . . 604
12.10Attribut volatile . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 605
12.11Traitement des exceptions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 609
12.A Quelques interfaces et classes disponibles dans java.util.concurrent . . . . . . . 610
12.B Comparaison des performances entre synchronized, ReentrantLock et AtomicInteger629
TABLE DES MATIÈRES 4

12.C Exemple avec streams (paquetage java.util.stream) . . . . . . . . . . . . . . . . 638


12.D Allocation dynamique de tableaux génériques . . . . . . . . . . . . . . . . . . . . . 640
12.E Exercices additionnels . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 642

VI Programmation parallèle par mémoire distribuée 650


13 Processus et échanges de messages ⇒ transparents 651

14 Message Passing Interface (MPI) ⇒ transparents 652

15 Exemples de programmes MPI 653


15.1 Petit pipeline avec trois processus . . . . . . . . . . . . . . . . . . . . . . . . . . . . 654
15.2 Intégration numérique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 659
15.3 Fonction mystere . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 661
15.4 Programme pour simuler la diffusion de la chaleur dans un cylindre . . . . . . . . . 662

16 Approche PCAM de Foster ⇒ transparents 670

17 Exécution et débogage de programmes parallèles MPI avec OpenMPI 671


17.1 Introduction : Caractéristiques d’un cluster et du cluster utilisé dans le cours . . . 672
17.2 Étapes pour l’exécution d’un programme MPI . . . . . . . . . . . . . . . . . . . . . 674
17.3 Stratégies pour déboguer un programme parallèle . . . . . . . . . . . . . . . . . . . 676
17.4 Erreurs typiques dans des programmes MPI . . . . . . . . . . . . . . . . . . . . . . 678
17.5 Options d’exécution avec OpenMPI . . . . . . . . . . . . . . . . . . . . . . . . . . . 680

18 Produit parallèle de matrices 684


18.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 684
18.2 Distribution des données par bloc . . . . . . . . . . . . . . . . . . . . . . . . . . . . 685
18.3 Méthode de Mattson, Sanders et Massingill [MSM05] . . . . . . . . . . . . . . . . . 690
18.4 Méthode de Fox [Pac97] . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 709
18.5 Méthode de Cannon . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 713

19 MapReduce: Simplified Data Processing on Large Clusters ⇒ transparents 714

20 Les trois principaux paradigmes d’interaction entre processus communiquant


par échanges de messages 715
20.1 Coordonnateur/travailleurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 716
20.2 Algorithmes systoliques . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 717
20.3 Algorithmes pipelines . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 717
20.4 Synthèse des différents exemples vus en cours . . . . . . . . . . . . . . . . . . . . . 719

VII Annexes 720


A La stratégie «diviser-pour-régner» pour la conception d’algorithmes récursifs 721
A.1 Diviser-pour-régner . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 721
A.2 Diviser-pour-régner générique. . . dans le contexte de la programmation fonctionnelle 736
A.3 Diviser-pour-régner générique. . . en Java . . . . . . . . . . . . . . . . . . . . . . . . 739
TABLE DES MATIÈRES 5

B Un aperçu de git, un outil de contrôle du code source 744


B.1 Introduction : Qu’est-ce qu’un système de contrôle du code source? . . . . . . . . . 745
B.2 Qu’est-ce que git? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 746
B.3 Quelques caractéristiques de git . . . . . . . . . . . . . . . . . . . . . . . . . . . . 747
B.4 Concepts de base de git . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 748
B.5 Les principales commandes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 750
B.6 Les trois niveaux d’un projet sour le contrôle de git : Exemple . . . . . . . . . . . 764
B.7 Stratégie d’utilisation pour un laboratoire ou devoir simple . . . . . . . . . . . . . 766
B.8 Comparaisons avec svn et CVS . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 768

Style de programmation et qualité du code 769


1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 769
2 Quelques principes généraux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 771
3 Abstraction procédurale : Caractéristiques d’une bonne méthode . . . . . . . . . . 774
4 La notion de couplage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 776
5 Refactoring . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 777
6 Exemples et contre-exemples en Ruby . . . . . . . . . . . . . . . . . . . . . . . . . 779

Références 791
Partie I

Introduction à la concurrence et au
parallélisme

6
Chapitre 1

Introduction : Programmation
concurrente et parallèle ⇒
transparents

Voir transparents à l’URL suivant :


http://www.labunix.uqam.ca/~tremblay/INF7235/Materiel/introduction.pdf

7
Chapitre 2

Aperçu des architectures parallèles


⇒ transparents

Voir transparents à l’URL suivant :


http://www.labunix.uqam.ca/~tremblay/INF7235/Materiel/architectures.pdf

8
Chapitre 3

Concepts de base de la concurrence


et du parallélisme

3.1 Processus vs. thread


Tant les processus que les threads représentent des instances de code en cours
d’exécution — donc une notion dynamique. Toutefois, on établit souvent les
distinctions suivantes entre processus et thread — voir aussi Figure 3.1 :
• Processus :
– Un processus représente un programme en cours d’exécution.
– Un processus possède un espace mémoire privé, indépendant de celui des
autres processus.
– Parce qu’ils ne partagent aucun espace mémoire commun, deux pro-
cessus qui veulent collaborer doivent le faire en utilisant un mécanisme
d’échange de messages — par exemple, pour des processus Unix, en
utilisant des pipes.
– Le contexte d’un processus est lourd : code, registres (dont SP, FP et
IP1 ), pile, tas2 , fichiers, vecteur d’interruptions, etc. Créer et activer un
processus — ainsi qu’effectuer un changement de contexte — est donc
(très!) coûteux!

• Thread :
– Un thread représente (généralement) une fonction (une méthode) en
cours d’exécution.
1
SP=Stack Pointer, FP=Frame Pointer, IP=Instruction Pointer.
2
Tas = heap.

9
Concepts de base 10

– Un processus peut contenir un ou plusieurs threads. Ces threads partagent


alors la mémoire (e.g., code, tas) ainsi que diverses autres ressources.
– Parce qu’ils partagent un même espace mémoire, deux threads qui veulent
collaborer peuvent le faire par l’intermédiaire de variables partagées.
– Le contexte d’un thread est léger : registres (dont IP), pile. Créer et
activer un thread — et effectuer un changement de contexte — est donc
moins coûteux que dans le cas d’un processus!

Figure 3.1: Processus vs. thread dans l’environnement Unix (source inconnue).
Concepts de base 11

Remarques :

• Il faut bien distinguer entre le programme en tant que «code» — code source
ou code compilé, une notion statique — et le programme en tant que «code
en cours d’exécution» — une notion dynamique.
Par exemple, un même programme Java — un même code (compilé) — peut
avoir plusieurs instances différentes en cours d’exécution (exemple Unix) :

$ javac Foo.java
$ java Foo & java Foo & java Foo &

La même remarque s’applique pour des thread s multiples lancés à partir de


l’exécution d’une même fonction ou méthode.

• Certains langages de programmation — par exemple, Erlang, Elixir, Scala, Go


— supportent une forme de processus légers. Ces processus légers possè-
dent, comme les processus lourds, un espace strictement privé, et donc com-
muniquent par l’intermédiaire d’envoi de messages. En fait, les processus de
ces langages sont souvent même plus légers que des threads. Ces processus
étant légers, la création et l’activation d’un tel processus — et un changement
de contexte — est donc peu coûteux, souvent moins que pour des threads.
Concepts de base 12

3.2 Concurrence vs. parallélisme


In programming, concurrency is the composition of independently execut-
ing processes, while parallelism is the simultaneous execution of (possibly
related) computations.
Concurrency is about dealing with lots of things at once.
Parallelism is about doing lots of things at once.
Source : Rob Pike, http: // blog. golang. org/ concurrency-is-not-parallelism

– Programme séquentiel

= Un programme qui comporte un seul fil d’exécution — un seul «thread »


⇒ Un seul doigt suffit pour indiquer l’instruction en cours d’exécution ,

– Programme concurrent

= Un programme qui contient deux ou plusieurs thread s qui coopèrent


⇒ Plusieurs doigts sont nécessaires pour indiquer les instructions en cours d’exécution ,

Coopération ⇒ Communication, échange d’information


Deux principales façons de communiquer :
– Par l’intermédiaire de variables partagées — principalement dans le cas
de threads
– Par l’échange de messages et de signaux — par ex., via des pipes dans le
cas de processus Unix

Est-ce vrai qu’un seul doigt suffit pour indiquer à quel endroit on est rendu dans
l’exécution d’un programme séquentiel?
Exercice 3.1: Un seul doigt suffit-il vraiment?
Concepts de base 13

– Différents types d’applications concurrentes — voir Figure 3.2 :

• Application multi-contextes (multi-threaded ) = contient deux ou plusieurs


thread s, qui peuvent ou non s’exécuter en même temps, et qui sont utilisés
pour mieux organiser et structurer l’application (meilleure modularité)
Exemples : Système d’exploitation multi-tâches, fureteurs multi-tâches, inter-
face personne-machine vs. traitement de la logique d’affaire, serveur Web

• Application parallèle = chaque thread s’exécute sur son propre processeur


(ou coeur), dans le but de résoudre plus rapidement un problème — ou pour
résoudre un problème plus gros
Exemples : Prévisions météorologiques, modélisation du climat, simulations
physiques, bio-informatique, traitement graphique, etc.

• Application distribuée = contient deux ou plusieurs processus, qui commu-


niquent par l’intermédiaire d’un réseau (⇒ délais plus longs), et ce pour ré-
partir, géographiquement, des données et des traitements
Exemples : Serveurs de fichiers, accès à distance à des banques de données

Les figures 3.3 à 3.6 illustrent les différences entre applications multicontextes et
applications parallèles.
Concepts de base 14

Figure 3.2: Programmes vs. programmes concurrents vs. applications multi-


contextes, parallèles ou distribuées.
Concepts de base 15

Figure 3.3: Exécution concurrente non parallèle vs. exécution concurrente et paral-
lèle de throis threads : tiré de [SMR09].
Concepts de base 16

À première vue, selon le diagramme de la Figure 3.3, il semblerait qu’une exécu-


tion concurrente mais non parallèle ne permette pas de réduire le temps d’exécution.
Toutefois, ce n’est pas toujours ainsi!
Dans la Figure 3.3, la partie du bas suppose que chacune des tâches indiquée
(chacune des lignes horizontales) peut s’exécuter sans interruption — par exemple,
il s’agit d’un programme CPU-bound : voir plus bas.
Par contre, si l’exécution doit être interrompue pour effectuer des entrées/sorties
— qui peuvent être très longues — alors une exécution avec threads vs. une exécu-
tion sans thread pourraient être telles que présentées à la Figure 3.4 : lorsqu’une
opération d’E/S doit être effectuée, une exécution concurrente avec threads peut ef-
fectuer un changement de contexte, pour éviter d’attendre inutilement et permettre
à un autre thread de progresser pendant que l’E/S se complète.

Figure 3.4: Exécution d’un programme effectuant des entrées/sortie de façon con-
currente avec threads vs. de façon séquentielle sans thread.

Pour un exemple plus détaillé, voir l’exemple présenté à la Section 3.6.


Concepts de base 17

Figure 3.5: Exécution d’une application multicontextes sur une machine séquentielle
vs. une machine parallèle.
Concepts de base 18

Figure 3.6: Exécution d’une application parallèle sur une machine séquentielle vs.
une machine parallèle.
Concepts de base 19

Autre caractérisation pour distinguer concurrence et paral-


lélisme : Programme CPU-bound vs. Programme IO-bound
Une caractéristique intéressante qui permet de comprendre la différence entre exé-
cution concurrente et exécution parallèle est celle de programme CPU-bound vs.
IO-bound :

• CPU-bound : un programme est CPU-bound si son temps d’exécution est


limité (contraint) par la vitesse du CPU.
On peut donc supposer qu’un programme CPU-bound passe la majeure partie
de son temps d’exécution à utiliser le CPU — il est gourmand en CPU — et
donc il s’exécuterait plus rapidement si le CPU était plus rapide.

• IO-bound : un programme est IO-bound si son temps d’exécution est limité


(contraint) par la vitesse du système d’entrées/sorties (accès disques, accès
réseaux, etc.).
Un programme IO-bound passe donc la majeure partie de son temps d’exécution
à utiliser les E/S — il est gourmand en E/S — et donc il s’exécuterait plus
rapidement si le système d’E/S était plus rapide.

Soit deux machines multi-coeurs M1 et M8 , ayant des configurations semblables sauf


pour le nombre de coeurs : un seul (1) coeur pour M1 et huit (8) coeurs pour M8 .

1. Soit un programme P1 qui est IO-bound et qui s’exécute sur M1 en 1.6 secon-
des.
Si on exécute P1 sur M8 , quel temps d’exécution peut-on s’attendre à obtenir?

2. Soit un programme P2 qui est CPU-bound et qui s’exécute sur M1 est 1.6
secondes.
Si on exécute P2 sur M8 , quel temps d’exécution peut-on s’attendre à obtenir?

Exercice 3.2: Effet d’ajouter des coeurs sur des programmes CPU-bound ou IO-
bound.
Concepts de base 20

Remarques additionnelles :

• Les catégories qui précèdent ne sont pas mutuellement exclusives. Par ex-
emple, de nombreuses machines parallèles modernes, qu’on utilise essentielle-
ment pour développer des applications parallèles, sont des multi-ordinateurs,
donc des machines composées d’un ensemble de processeurs (avec mémoire dis-
tribuée) interconnectés par un réseau. On programme souvent ces machines
avec des langages de programmation où l’on doit tenir compte de la distribu-
tion (principalement des données) et où les échanges d’information se font par
l’envoi de messages.

• Dans le cadre du cours, nous traiterons principalement d’applications paral-


lèles, puis d’applications concurrentes, mais pas d’applications distribuées.

• Dans le cours, nous utiliserons les termes suivants :

– Programmation parallèle = Programmation pour développer une ap-


plication parallèle et l’exécuter sur machine parallèle.
Dans cette forme de programmation, l’objectif sera d’obtenir un pro-
gramme qui, si possible, s’exécute plus rapidement.
– Programmation concurrente = Programmation pour développer une
application concurrente, plus généralement une application multi-contextes,
et l’exécuter sur machine arbitraire — séquentielle, multi-coeurs, multi-
processeurs, etc.
Dans ce cas, l’objectif sera typiquement de développer un programme
comportant plusieurs threads qui partagent des ressources de façon
correcte et efficace.

• Voir aussi l’exemple présenté à la Section 3.6.


Concepts de base 21

3.3 Contrôle de la concurrence


3.3.1 État d’un thread, actions atomiques et entrelacement
des instructions atomiques
Quelques définitions :

• L’état d’un thread est caractérisé par les valeurs des variables du thread à
un instant donné, y compris les variables implicites, notamment le compteur
d’instruction (IP).

• Action atomique = Une action qui examine ou change l’état d’un thread de
façon indivisible.

• Un thread exécute une séquence d’instructions. Chaque instruction peut elle-


même être mise en oeuvre par une séquence d’une ou plusieurs actions
atomiques associées à des instructions machines.

• L’effet de l’exécution d’un programme concurrent est obtenu par l’entrelacement


des diverses actions atomiques effectuées par chacun des thread s.
Concepts de base 22

Programme Ruby 3.1 Petit programme Ruby avec deux thread s simples.
PRuby . pcall (
-> { puts " Thr1 : ."
puts " Thr1 : .. "
puts " Thr1 : ... "
},
-> { puts " Thr2 : +"
puts " Thr2 : ++ "
puts " Thr2 : +++ "
}
)

puts " Fin du programme "

Soit le Programme Ruby 3.1. La méthode pcall de la bibliothèque PRuby —


méthode de classe (statique) — reçoit en argument deux ou plusieurs lambdas. Ces
différents lambdas sont alors évalués de façon concurrente — en parallèle si les
ressources le permettent. L’appel à pcall ne se termine que lorsque tous les appels
des lambdas ont complété. Un appel à la méthode pcall est donc bloquant,
introduisant une forme de barrière de synchronisation entre les threads exécutant
les lambdas. «Typiquement», l’exécution de ce programme produira le résultat
suivant :
$ ruby entrelacement.rb
Thr1: .
Thr1: ..
Thr1: ...
Thr2: +
Thr2: ++
Thr2: +++
Fin du programme

Ici, c’est ce résultat qui est produit parce que les deux threads sont petits et
simples, et donc le premier thread peut s’exécuter rapidement et entièrement parfois
même avant que le deuxième thread ne s’exécute.
Concepts de base 23

Toutefois, il est aussi possible que, parfois, ce programme produise plutôt un


résultat tel que le suivant :
$ ruby entrelacement.rb
Thr1: .Thr2: +

Thr1: ..
Thr1: ...
Thr2: ++
Thr2: +++
Fin du programme
Concepts de base 24

3.3.2 Comment et pourquoi faire varier intentionnellement


la vitesse d’exécution des threads

Programme Ruby 3.2 Petit programme Ruby avec deux thread s simples, mais
avec des temps d’exécution variables.
def jiggle
sleep rand / 10.0
end

PRuby . pcall (
-> { puts " Thr1 : ."
jiggle
puts " Thr1 : .. "
jiggle
puts " Thr1 : ... "
},
-> { puts " Thr2 : +"
jiggle
puts " Thr2 : ++ "
jiggle
puts " Thr2 : +++ "
}
)

puts " Fin du programme "

The reason that threading bugs can be infrequent, sporadic, and hard to
repeat, is that only a very few pathways out of the many thousands of
possible pathways through a vulnerable section can actually fail.
The point is to jiggle the code so that threads run in different orderings
at different times. The combination of well-written tests and jiggling can
dramatically increase the chance of finding errors.

«Clean Code», R.C. Martin

Pour qu’un programme concurrent soit considéré correct, il doit produire le bon
résultat peu importe la vitesse à laquelle s’exécute chacun des thread s.
Soit alors le Programme PRuby 3.2, une version modifiée du Programme PRuby 3.1
dans lequel on a introduit un délai arbitraire d’exécution entre chacune des instruc-
Concepts de base 25

tions puts. Un tel délai — introduit ici par un appel à la méthode jiggle3 —
permet de modéliser explicitement des variations dans la vitesse d’exécution des
thread s, ce qui est souvent utile pour déboguer un programme concurrent.
L’exemple d’exécution 3.1 (p. 26) illustre divers résultats d’exécution possibles
du Programme Ruby 3.2 : les deux premiers éléments de la trace sont toujours
les mêmes — à cause de l’ordre de lancement des deux thread s et de l’absence de
jiggle au début du thread — mais ensuite l’ordre change d’une exécution à l’autre
(ou presque ,) selon la durée du délai aléatoire produit par jiggle. On peut noter
aussi la dernière exécution, où même les deux chaînes émises sur la sortie standard
sont entrelacées — l’une des chaînes s’imprime avant que le saut de ligne de la chaîne
précédente n’ait été imprimé.
3
Traduction de jiggle : secouer, se trémousser.
Concepts de base 26

$ ruby entrelacement.rb
Thr1: .
Thr2: +
Thr1: ..
Thr1: ...
Thr2: ++
Thr2: +++
Fin du programme

$ ruby entrelacement.rb
Thr1: .
Thr2: +
Thr2: ++
Thr2: +++
Thr1: ..
Thr1: ...
Fin du programme

$ ruby entrelacement.rb
Thr1: .
Thr2: +
Thr2: ++
Thr1: ..
Thr1: ...
Thr2: +++
Fin du programme

$ ruby entrelacement.rb
Thr1: .
Thr2: +
Thr2: ++
Thr2: +++Thr1: ..

Thr1: ...
Fin du programme

...

Exemple d’exécution 3.1: Divers exemples d’exécution du Programme Ruby 3.2,


version avec des jiggles du Programme Ruby 3.1.
Concepts de base 27

3.3.3 Situation de compétition


Situation de compétition, plus couramment nommée race condition,
est un défaut dans un système [. . . ] informatique, [. . . ] caractérisé par
un résultat différent selon l’ordre dans lequel sont effectuées
certaines opérations du système.
Source : http://fr.wikipedia.org/wiki/Situation_de_comp%C3%A9tition

En anglais, on parle de «race condition» — littéralement, «condition de course».


L’utilisation du terme «course» tient au fait qu’une telle situation de compétition
peut survenir parce que les vitesses d’exécution des thread s sont imprévisibles et/ou
varient (attente pour des ressources, allocation du temps processeur, etc.) et que le
résultat produit par l’exécution du programme dépend de ces vitesses d’exécution.
La notion d’entrelacement ainsi que celle de situation de compétition dépendent
de ce qui est considéré comme une «instruction atomique».

Programme Ruby 3.3 Programme avec deux thread s qui modifient une variable
partagée avec l’opération «+=».
x = 0

PRuby . pcall (
-> { x += 1 } ,
-> { x += 2 }
)

puts " x = #{ x } "

Soit le programme Ruby 3.3. Le résultat produit typiquement par l’exécution


de ce programme sera le suivant :
$ ruby competition.rb
x = 3

Par contre, de temps en temps, le résultat imprimé sera x = 2 ou x = 1!


Le résultat le plus typique, et attendu, est produit pour les mêmes raisons que
précédemment : le premier thread qui est lancé s’exécute de façon simple et rapide.
Par contre, ce programme contient effectivement une situation de compétition, qu’on
peut reproduire plus facilement en le modifiant pour obtenir le programme Ruby 3.4 :

• On décompose les instructions non-atomiques en instructions plus simples.


Ainsi, une instruction telle que «x += 1» n’est pas une instruction atomique.
Concepts de base 28

Elle correspond en fait à l’instruction «x = x + 1», laquelle correspond à


un séquence suivante d’instructions de bas niveau semblable à la suivante :
«reg = x; reg = reg + 1; x = reg».4
• On introduit des délais arbitraires avec jiggle à divers endroits.

L’exemple d’exécution 3.2 donne alors des résultats possibles d’exécution, mon-
trant clairement la présence d’une situation de compétition.

Programme Ruby 3.4 Programme avec deux thread s qui modifient une variable
partagée avec l’opération «+=», mais avec des délais introduits entre les opérations
non-atomiques.
x = 0

PRuby . pcall (
-> { jiggle ; reg = x ; jiggle ; reg = reg + 1; x = reg } ,
-> { jiggle ; reg = x ; jiggle ; reg = reg + 2; x = reg }
)

puts " x = #{ x } "

4
En fait, dans une machine RISC typique, où les opérations arithmétiques se font uniquement
sur les registres, on aurait les (pseudo-)instructions suivantes :
LOAD r1, x
r1 = r1 + 1
STORE r1, x
Concepts de base 29

$ ruby competition2.rb
x = 2

$ ruby competition2.rb
x = 1

$ ruby competition2.rb
x = 3

$ ruby competition2.rb
x = 3

$ ruby competition2.rb
x = 2

Exemple d’exécution 3.2: Exemples d’exécution du programme Ruby 3.4.

Indépendance entre threads et absence de situation de compétition


Une situtation de compétition entre deux threads peut survenir uniquement si ces
threads partagent une ressource — par ex., une variable. Lorsqu’il n’y a aucun
partage de ressources, il y a alors indépendance entre les threads, et donc aucun
danger de situation de compétition. Dans ce cas, les deux threads peuvent alors
s’exécuter en parallèle sans problème, sans synchronisation particulière.
Plus précisément, il peut y avoir situation de compétition uniquement si (au
moins) un des threads modifie la ressource partagée. Si les deux threads ne font
que lire la ressource, alors aucun conflit n’est possible.
Pour plus de détails, voir plus loin (section 3.5).
Concepts de base 30

Soit l’algorithme suivant, où l’on suppose que la ligne retournée est EOF lorsque la
fin de fichier est atteinte :

PROCEDURE trouver_motif( fich, motif )


DEBUT
ouvrir le fichier fich
ligne ← lire une ligne du fichier fich
TANTQUE ligne 6= EOF FAIRE
ecrire ligne SI motif est present dans ligne
ligne ← lire une ligne du fichier fich
FIN
FIN

Est-il possible de paralléliser la procédure trouver_motif, c’est-à-dire, de faire en


sorte que certaines tâches à l’intérieur de la procédure s’exécutent de façon concur-
rente?
On désire évidemment que les lignes du fichiers soient lues dans le bon ordre, et
émises dans le bon ordre si elles satisfont le motif.
Indice : Introduire une variable auxiliaire. . .
Exercice 3.3: Parallélisation de la recherche de motifs dans un fichier.

Situation de compétition et résultat non-déterministe


Si deux parties de programme sont indépendantes, alors elles peuvent être exécutées
en parallèle sans qu’il n’y ait d’interférence, car il n’y a aucun danger de situation de
compétition. Par contre, si deux parties de programme ne sont pas indépendantes,
alors elles peuvent être exécutées en parallèle, soit. . .
• en ajoutant des opérations de synchronisation pour éviter les interférences in-
désirables, donc pour supprimer les situations de compétition — voir prochaine
section.
• en les exécutant telles quelles si un résultat non-déterministe est acceptable.

Situation de compétition et erreur de programmation


On dit d’un programme concurrent qu’il contient une erreur de programmation as-
sociée à une situation de compétition si une des exécutions possibles (n’importe
laquelle parmi les divers entrelacements possibles) conduit à un résultat erroné /
Une autre façon de présenter les choses est la suivante. Supposons que je suis
en train de corriger un programme concurrent que vous avez écrit. Si je réussis, en
ajoutant quelques instructions jiggle dans votre programme, à faire produire un
résultat qui n’est pas le bon, alors c’est que votre programme n’est pas correct!
Concepts de base 31

3.3.4 Atomicité et exclusion mutuelle


Il y a deux principales façons de modifier un programme concurrent pour éviter
les situations de compétition, et ce en ajoutant des instructions qui vont exclure
certains entrelacements — soit en rendant atomiques des séquences d’instructions,
soit en retardant l’exécution d’une séquence d’instructions :
• Exclusion mutuelle = Permet de combiner des groupes d’instructions en
sections critiques de façon à les rendre indivisibles, donc atomiques, ce qui
fait que les instructions internes ne peuvent plus être entrelacées avec les in-
structions internes d’autres sections critiques.
• Synchronisation conditionnelle = Permet de retarder une action ou une
instruction jusqu’à ce que l’état du système satisfasse une certaine condition.
Dans ce qui suit, nous illustrons l’exclusion mutuelle ; la synchronisation condi-
tionnelle sera étudiée dans un chapitre ultérieur.

Programme Ruby 3.5 Programme avec deux thread s qui modifient une variable
partagée avec l’opération «+=», et ce à l’intérieur d’une section critique correctement
protégée par un verrou.

mutex = Mutex . new

x = 0

PRuby . pcall (
-> { mutex . synchronize { x += 1 } } ,
-> { mutex . synchronize { x += 2 } }
)

puts " x = #{ x } "

Le programme Ruby 3.5 est une version révisée du programme Ruby 3.3, mais
cette fois sans situation de compétition, donc le programme imprimera toujours
«x = 3».
Dans les deux thread s, la modification de x est effectuée à l’intérieur d’une section
critique protégée par un verrou d’exclusion mutuelle — objet mutex instance de la
classe Mutex. Lorsqu’on utilise un tel verrou, le bloc de code qui suit synchronize
est assuré de s’exécuter de façon complètement atomique, sans interférence par
d’autres thread s exécutant eux aussi des sections critiques protégées par le même
verrou!
Concepts de base 32

Quelques remarques additionnelles :


• Un appel tel que «mutex.synchronize { x+= 1 }» est en fait équivalent au
segment de code suivant :
mutex . lock
x += 1
mutex . unlock

• La forme avec lock/unlock permet de comprendre plus en détail comment


fonctionne un tel Mutex — qu’on appelle aussi un verrou d’exclusion mutuelle.
Un tel verrou peut être dans l’un de deux états possibles suivants :
– Déverrouillé : Dans cet état, un appel à lock met le verrou dans l’état
verrouillé, et le thread qui appelle lock poursuit immédiatement son exé-
cution, sans bloquer.
– Verrouillé : Dans cet état, le thread qui appelle lock est suspendu, donc
reste bloqué, en attente que le verrou redevienne libre (déverrouillé).
Toujours dans cet état, lorsqu’un thread ayant acquis le verrou appelle
unlock, alors le verrou redevient libre, i.e., déverrouillé. Si un ou plusieurs
thread s étaient suspendus en attente du verrou, alors un d’entre eux —
et un seul! — obtiendra le verrou. . . et donc le verrouillera à nouveau.
• Pour qu’un accès à une variable soit correctement protégé par une section
critique atomique, il faut évidemment que tous les accès à cette variables le
soient aussi. Donc, dans le programme précédent, si le deuxième thread du
pcall est défini comme suit, alors le programme contiendra une situation de
compétition :
-> { x += 2 }

• La description qui précède est générale, applicable à la plupart des langages de


programmation modernes. Par contre, le comportement exact lors d’un lock
ou unlock pourra dépendre du langage ou de la bibliothèque de thread s :
– Dans les langages de haut niveau, un thread qui est mis en en attente
d’un verrou sera suspendu, au sens où il ne consommera pas de temps
CPU pendant son attente. On parle alors d’une attente passive. Par
contre, des thread s de très bas niveau pourraient attendre de façon active,
e.g., en bouclant jusqu’à ce qu’un certain bit devienne à 1.
– Dans certains langages, une erreur sera signalée si le thread qui effectue
l’appel à unlock n’est pas le même qui celui qui a fait l’appel à lock.
Concepts de base 33

– Dans certains langages, un thread peut acquérir (avec lock) un verrou


qu’il a déjà acquis mais qu’il n’a pas encore libéré — on parle alors de
verrou réentrant. Par contre, dans d’autres langages, une telle situation
conduira à un deadlock — voir section suivante — parce que le thread
bloquera à cause de la non-disponibilité du verrou, déjà pris. . . par le
même thread !

Soit le segment de code Ruby suivant qui trouve l’élément maximum parmi un
tableau de nombres entiers positifs :
a = [10 , 62 , 173 , 823 , 32 , 99 , 9292 , 0 , 1]

m = 0
PRuby . pcall ( 0... a . size ,
- >( i ) { m = a [ i ] if a [ i ] > m }
)

puts " maximum = #{ m } "

1. Est-ce que ce programme est correct? Justifiez votre réponse.

2. S’il n’est pas correct, comment peut-on le rendre correct?

Exercice 3.4: Recherche parallèle de l’élément maximum d’un tableau


Concepts de base 34

3.3.5 Situation d’interblocage


Une situation d’interblocage — un deadlock — survient lorsque tous les thread s d’un
programme sont bloqués en attente d’un événement ou d’une condition. . . mais qui
ne pourra jamais survenir, par exemple, parce que tous les threads sont bloqués.
Un exemple classique est celui d’une dépendance cyclique entre thread s — on
parle alors d’un cas de deadly embrace.

Programme Ruby 3.6 Petit programme Ruby illustrant une situation potentielle
d’interblocage.
mut1 = Mutex . new
mut2 = Mutex . new

PRuby . pcall (
-> { mut1 . synchronize {
mut2 . synchronize {
puts " Dans thr1 "
}
}
},
-> { mut2 . synchronize {
mut1 . synchronize {
puts " Dans thr2 "
}
}
}
)

puts " Fin du programme "

Le Programme Ruby 3.6 présente un exemple simple. Si on exécute ce pro-


gramme, un résultat possible d’exécution pourrait être le suivant :
$ ruby interblocage.rb
Dans thr1
Dans thr2
Fin du programme

Par contre, dans certains cas, . . . rien n’arrive, rien n’est imprimé! Pour ex-
pliquer ce dernier comportement, supposons la séquence suivante d’exécution des
instructions de thr1 et thr2 — i.e., un entrelacement possible de l’exécution de
Concepts de base 35

leurs instructions — obtenu par exemple en ajoutant «jiggle» dans thr1 après
l’acquisition du premier verrou :

• Le thread thr1 acquiert avec succès le verrou mut1.


• Le thread thr2 acquiert avec succès le verrou mut2.
Le thread thr2 tente d’acquérir le verrou mut1, lequel n’est pas disponible,
donc thr2 est suspendu.
Le thread thr1 tente d’acquérir le verrou mut2, lequel n’est pas disponible,
donc thr1 est suspendu.
• Oops! Situation d’interblocage : thr1 attend après thr2 (pour qu’il libère
mut2) et thr2 attend après thr1 (pour qu’il libère mut1).

Dans cet exemple où deux verrous sont utilisés, la solution pour éviter un in-
terblocage est relativement simple : il faut toujours acquérir les deux verrous dans
le même ordre, donc interchanger les deux premières instructions de thr2.
Concepts de base 36

3.4 Algorithmes parallèles et graphes de dépendances


des tâches
3.4.1 La notion de tâche vs. les notions d’unité d’exécution
et de thread
Lorsqu’on développe un algorithme parallèle, la première étape consiste à identifier
toutes les tâches possibles, pour mieux identifier ensuite ce qui pourrait être fait
en parallèle.
Lorsqu’on parle de tâche, on parle ici d’une notion logique, liée au problème et à
sa solution exprimée de façon abstraite, donc de niveau algorithmique. Une tâche
est alors un ensemble d’instructions, d’opérations arithmétiques ou logiques simples,
qui «fait du sens» dans le contexte de l’algorithme.
Il faut donc distinguer la notion de tâche de celle d’unité d’exécution, qui est une
notion liée à l’exécution d’un programme parallèle avec un langage et une machine
parallèle.
Dans ce qui suit, nous allons utiliser le terme d’«unité d’exécution» — traduction
du terme execution unit, que nous allons parfois abrévier par UE — pour dénoter
un élément actif qui exécute du code. Une UE pourrait donc être un des éléments
suivants :

• un processeur d’une machine multi-processeurs ;

• un coeur d’une machine multi-coeurs ;

• un thread matériel d’une machine multi-threaded ;

• un thread d’un langage ou d’une bibliothèque de programmation parallèle ;

Ce dernier cas tient au fait que dans certains langages, les threads d’un pro-
gramme peuvent être considérés comme des unités d’exécution, dans la mesure où
on lance au départ un certain nombre de threads, qui exécutent ensuite les diverses
tâches exprimés par l’algorithme et le programme.
Dans les chapitres qui suivent, nous réserverons donc la notion de thread pour
des objets actifs créés et manipulés par le langage utilisé pour programmer notre
algorithme parallèle. Dans ce qui suit, nous traiterons plutôt de tâches, au niveau
logique et algorithmique.
Concepts de base 37

3.4.2 Tâches, dépendances entre tâches et graphes de dépen-


dances
Pour paralléliser un algorithme, il faut identifier toutes les tâches possibles, mais
aussi identifier leurs dépendances, pour déterminer ce qui peut, ou non, se
faire en parallèle.

Au niveau algorithmique, on commence par identifier les tâches les plus fines
possibles (granularité aussi fine que nécessaire en fonction du problème : voir plus
bas), sans tenir compte des ressources ou contraintes de la machine — en d’autres
mots, on suppose une machine «idéale» avec des ressources illimitées.
Ensuite, on détermine les dépendances entre ces tâches : si une tâche T1 doit
s’exécuter avant une tâche T2 , par exemple parce que T1 produit un résultat utilisé
par T2 , alors on dit qu’il y a une dépendance (de données) de T1 vers T2 . On peut
ainsi construire un graphe des dépendances de tâches. Ce graphe ne permet
ensuite de mieux comprendre le comportement de l’algorithme parallèle.
C’est souvent aussi à partir de ce graphe qu’on pourra développer un programme
parallèle efficace, qui tiendra compte des ressources matérielles — par exemple,
pour diminuer les coûts de synchronisation et communication, on pourra décider
de combiner ensemble des petites tâches pour obtenir des tâches plus grosses. On
traitera de ces questions dans un chapitre ultérieur.
Concepts de base 38

3.4.3 Un exemple de graphes de dépendances des tâches : le


calcul des racines d’un polynome de 2e degré
Un polynome p(x) de 2e degré est défini par trois coefficients a, b et c, tels que :

p(x) = ax2 + bx + c

Une racine r de p(x) est une valeur telle que p(r) = 0. Pour un polynome
de 2e degré, la formule suivante permet de trouver les racines, en supposant ici
qu’au moins une racine existe (b2 ≥ 4ac) :

−b + b2 − 4ac
r1 =
2a

−b − b2 − 4ac
r2 =
2a

Pour cet exemple, nous allons décomposer le calcul de r1 en une suite d’instructions
simples dites «à trois adresses», soit deux opérandes, une destination pour le résultat
et un opérateur — donc des pseudo-instructions machines ou du pseudo code-octet.
Ce seront alors ces instructions qui représenteront nos tâches à paralléliser.
Remarque : En pratique, on ne procèra généralement pas à une décomposition
aussi fine des instructions. Ici, on le fait pour illustrer plus clairement les notions
de dépendances et de graphes de dépendances.
Voici un segment de code pour calculer la racine r1 et l’affecter à la variable r1 :
t0 = -1 * b
t1 = b * b
t2 = 4 * a
t3 = t2 * c
t4 = t1 - t3
t5 = sqrt t4
t6 = t0 + t5
t7 = 2 * a
r1 = t6 / t7
La Figure 3.7 présente un graphe de dépendances pour ces tâches :

• Un cercle dénote une tâche. Son contenu indique l’instruction à exécuter pour
cette tâche.

• Une flèche allant d’une tâche vers une autre indique que la deuxiême tâche
— celle au bout de la flèche — ne peut s’exécuter que lorsque la première a
Concepts de base 39

Figure 3.7: Graphe de dépendances des tâches pour le calcul de la racine r1 d’un
polynome de 2e degré.
Concepts de base 40

complété son exécution. Une dépendance impose donc un ordre d’exécution


séquentiel entre ces deux tâches.
Notons qu’ici, il s’agit strictement de dépendances de données : une tâche T2
— par ex., r1 = t6/t7 — dépend d’une autre tâche T1 — par ex., t7 = 2*a
— parce que T2 utilise le résultat calculé par T1 .
Concepts de base 41

3.4.4 Degré de parallélisme


Le degré de parallélisme d’un algorithme parallèle à un instant donné est le
nombre de tâches qui peuvent être exécutées en même temps à cet instant. Ce
nombre peut évidemment varier en cours d’exécution.
La performance d’un programme parallèle dépend de son degré de parallélisme et
du nombre de processeurs de la machine : si la machine possède plus de processeurs
que le degré de parallélisme, alors le programme pourra s’exécuter à sa plus grande
vitesse parallèle.
Pour plus facilement évaluer le degré de parallélisme pour le graphe de la Fi-
gure 3.7, on peut le modifier pour obtenir le graphe de la Figure 3.8. Il s’agit
exactement des mêmes tâches et des mêmes dépendances — donc du même graphe
— mais présentées de façon à ce qu’on voit bien, à un instant donné, les tâches qui
sont prêtes à s’exécuter.
Le degré de parallélisme à chaque instant est donc comme suit :

Temps Degré de
parallé-
lisme
0 4
1 1
2 1
3 1
4 1
5 1

Une autre notion intéressante pour comprendre le comportement d’un programme


parallèle est celle de «degré moyen de parallélisme» = nombre moyen de tâches
qui peuvent être exécutées en parallèle tout au long des différents moments de
l’exécution. Ainsi, dans notre exemple, le degré moyen de parallélisme serait le
suivant :
4+1+1+1+1+1 9
= = 1.5
6 6
Lorsque ce degré moyen de parallélisme est semblable au degré maximum, alors
cela implique que les ressources de la machine pourront être utilisées de façon effi-
ciente tout au long de l’exécution du programme. Inversement, si l’écart est grand,
alors à certains instants on aura besoin d’un grand nombre de processeurs, alors qu’à
d’autres moments on aura besoin de peu de processeurs — donc soit on «acquiert»
un grand nombre de processeurs et plusieurs resteront parfois ou souvent inutilisés,
soit on utilise moins de processeurs, ce qui augmentera le temps d’exécution puisque
certaines tâches indépendanntes ne pourront pas être exécutées de façon parallèle.
Concepts de base 42

Figure 3.8: Version révisée du graphe de dépendances des tâches pour le calcul de
la racine r1 d’un polynome de 2e degré.
Concepts de base 43

3.4.5 Longueur du chemin critique et temps d’exécution paral-


lèle idéal
Un chemin dans un graphe entre deux sommets S1 et S2 est une série de sommets
et d’arcs qui permettent d’aller de S1 à S2 .
La longueur d’un chemin est le nombre d’arcs traversés par ce chemin.

Dans un graphe de dépendances des tâches, le plus long chemin allant de la


tâche initiale à la tâche finale est appelé le chemin critique. C’est cette longueur
— dite longueur du chemin critique (critical path length) — qui détermine le
meilleur temps d’exécution possible.

Dans notre exemple, comme on le voit bien dans la Figure 3.8, la longueur du
chemin critique est de 5. En supposant que chaque tâche s’exécute en une (1)
unité de temps, il faudra donc, au mieux, 6 unités de temps pour que l’algorithme
s’exécute.
Plus exactement, le meilleur temps d’exécution possible varie en fonction du
nombre d’unités d’exécution disponibles, comme l’indique le tableau suivant :

Nb. Temps
d’UE min.
1 9
2 6
3 6
4 6
... ...

On remarque que même si le degré maximum de parallélisme est de 4, l’utilisation


de 3 UE ne produira pas un meilleur temps d’exécution que si on utilise seulement
2 UE! Ceci s’explique par la longue chaîne séquentielle de dépendances — entre les
calculs de t3, t4, t5, t6 et r1!

Temps parallèle idéal


Le temps parallèle minimum — ou idéal — d’exécution d’un algorithme parallèle
est celui pouvant être obtenu en utilisant autant d’unités d’exécution que nécessaire
— donc en supposant une machine idéale, sans limite sur le nombre d’UE. Ce temps
minimum idéal est simplement égal à 1+la longueur du chemin critique du graphe
de dépendance des tâches.
Concepts de base 44

Choix de la cédule d’exécution


Soulignons que le temps d’exécution pour un algorithme parallèle avec un certain
nombre d’UE n’est pas nécessairement unique. Pour obtenir le meilleur temps pos-
sible, il faut choisir une bonne cédule, i.e., faire un bon choix parmi les différentes
tâches à exécuter. Pour notre exemple avec 2 UE, voici une cédule possible, où les 2
UE sont utilisées uniquement durant les trois premières unités de temps :
0: t0, t2
1: t1, t3
2: t7, t4
3: t5
4: t6
5: r1

Voici par contre une autre cédule, qui conduit à un temps d’exécution plus long :
0: t0, t1
1: t2, t7
2: t3
3: t4
4: t5
5: r6
6: r7

De façon générale, le problème de sélectionner la meilleure cédule d’ordonnacement


est un problème NP-complet /
Concepts de base 45

3.4.6 Degré de parallélisme et dimensionnement (scalability )


Dans plusieurs problèmes, l’ajout d’UE additionnelles n’a aucun d’effet C’est /
le cas pour notre exemple précédent — si on choisit une bonne cédule, l’utilisation
de 3 UE n’apporte pas d’amélioration par rapport à l’utilisation de 2 UE. Un tel
problème n’est donc très parallélisable — pas dimensionable.

• On dit d’un algorithme (ou d’un programme) parallèle qu’il est dimensionnable
(scalable) lorsque son degré de parallélisme augmente au moins de façon linéaire
avec la taille du problème.5
• On dit d’une architecture qu’elle est dimensionnable si la machine continue
à fournir les mêmes performances par processeur lorsque l’on accroît le nombre
de processeurs.

L’importance d’avoir un algorithme/programme et une machine dimensionnables


vient de ce que cela permet de résoudre des problèmes de plus grande taille sans
augmenter le temps d’exécution, et ce simplement en augmentant le nombre de
processeurs utilisés.

5
«Scalability is a parallel system’s ability to gain proportionate increase in parallel speedup with
the addition of more processors.» [Gra07].
Concepts de base 46

3.4.7 Granularité des tâches


Une notion utile pour développer un programme parallèle efficace est celle de granula-
rité des tâches.

In parallel computing, granularity means the amount of computation in


relation to communication, i.e., the ratio of computation to the amount
of communication.
Fine-grained parallelism means individual tasks are relatively small in
terms of code size and execution time. The data is transferred among
processors frequently in amounts of one or a few memory words. Coarse-
grained is the opposite: data are communicated infrequently, after larger
amounts of computation.
The finer the granularity, the greater the potential for parallelism and
hence speed-up, but the greater the overheads of synchronization and
communication. In order to attain the best parallel performance, the best
balance between load and communication overhead needs to be found.

If the granularity is too fine, the performance can suffer from the in-
creased communication overhead. On the other side, if the granularity
is too coarse, the performance can suffer from load imbalance.
Source : http: // en. wikipedia. org/ wiki/ Granularity

Note : Dans le cas d’un programme parallèle avec mémoire partagée, on peut
substituer «communication» par «synchronisation».
On discutera de la notion de granularité plus en détail lorsqu’on traitera de
méthodologie de programmation parallèle.
Concepts de base 47
Concepts de base 48

3.4.8 Autres exemples de graphes de dépendances des tâches


Graphe de flux de données pour le calcul de la racine d’un
polynome de 2e degré

Figure 3.9: Graphe de flux de données pour le calcul de la racine r1 d’un polynome
de 2e degré.
Concepts de base 49

La Figure 3.9 présente une façon simplifiée de représenter le graphe de dépendances


des tâches pour le calcul de la racine d’un polynome de 2e degré, et ce à l’aide d’un
graphe de flux de données.
Dans ce graphe, une tâche est simplement indiquée par l’opération arithmétique
devant être exécutée — avec un opérateur unaire si la tâche ne possède qu’une seule
entrée (par ex., sqrt), ou un opérateur binaire si la tâche possède deux entrées. Les
dépendances sont alors toutes des dépendances de données.
Concepts de base 50

Somme de deux tableaux

Figure 3.10: Graphe de flux de données pour un algorithme parallèle calculant la


somme de deux tableaux de 8 éléments.

La Figure 3.10 présente un graphe de flux de données pour un algorithme paral-


lèle effectuant la somme de deux tableaux de 8 éléments, algorithme pouvant être
exprimé par le code PRuby suivant :
PRuby . pcall ( 0..7 ,
-> { | k | c [ k ] = a [ k ] + b [ k ] }
)
On constate qu’il n’y a aucune dépendances entre les tâches. On dit d’un tel
algorithme qu’il est embarrassingly parallel.
Concepts de base 51

Sommation des éléments d’un tableau


On a un tableau de 8 éléments et on veut faire la somme de ces éléments. Voici une
suite d’instructions à trois adresses permettant d’effectuer ce calcul :

t0 = a[0] + a[1]
t1 = t0 + a[2]
t2 = t1 + a[3]
t3 = t2 + a[4]
t4 = t3 + a[5]
t5 = t4 + a[6]
somme = t5 + a[7]

Figure 3.11: Graphe de flux de données pour un algorithme effectuant la sommation


des éléments d’un tableau de 8 éléments.

La Figure 3.11 présente le graphe de flux de données associé à ce calcul. On


constate que cette façon de procéder est strictement séquentielle — 7 unités de
Concepts de base 52

temps sont nécessaires pour effectuer la somme de 8 éléments. L’algorithme est


donc O(n).
Voici une autre suite d’instructions à trois adresses permettant d’effectuer ce
même calcul, mais de façon différente :

t0 = a[0] + a[1]
t1 = a[2] + a[3]
t2 = a[4] + a[5]
t3 = a[6] + a[7]
t4 = t0 + t1
t5 = t2 + t3
somme = t4 + t5

Figure 3.12: Deuxième version — parallélisable! — d’un graphe de flux de données


pour un algorithme effectuant la sommation des éléments d’un tableau de 8 éléments.

La Figure 3.12 présente le graphe de flux de données associé à ce calcul. On


constate que cette façon de procéder peut se faire de façon parallèle — — 3 unités
de temps sont nécessaires pour effectuer la somme de 8 éléments. L’algorithme est
O(lg n) !
Concepts de base 53

Sommation récursive des éléments d’un tableau

Programme Ruby 3.7 Algorithme récursif pour effectuer la sommation des élé-
ments d’un tableau.
def somme ( a , i , j )
if i == j
a[i]
else
mid = ( i + j ) / 2

somme (a , i , mid ) + somme (a , mid +1 , j )


end
end

Le Programme Ruby 3.7 présente un algorithme récursif pour effectuer la som-


mation des éléments d’un tableau.
Concepts de base 54

Figure 3.13: Graphe de dépendances de tâches pour le calcul récursif de la somma-


tion des éléments d’un tableau.

La Figure 3.13 présente un graphe de dépendances pour cet algorithme récursif :

• On représente une activation de fonction avec un rectangle, les arguments de


la fonction étant indiqués par un petit rectangle dans le coin supérieur gauche.

• Certaines tâches — branche then vs. branche else — sont exécutées unique-
ment si une certaine condition est satisfaite. Dans ce cas, on parle alors d’une
dépendance de contrôle. Ici, ce type dépendance est indiqué par une flèche
en pointillée, la tâche qui calcule la condition étant indiquée par un rectangle
pointillé aux coins arrondis.
Concepts de base 55

Figure 3.14: Graphe de dépendances de tâches pour le calcul récursif de la somma-


tion des éléments d’un tableau de 8 éléments.

La Figure 3.14 présente le graphe de dépendances généré pour le cas spécifique


d’une sommation d’un tableau comptant 8 éléments. On constate que la structure
est semblable à celle du graphe de la Figure 3.12.

Figure 3.15: Arbre d’activations des instances de fonction pour le calcul récursif de
la sommation des éléments d’un tableau de 8 éléments.

Quant à la Figure 3.15, elle présente un arbre d’activation des fonctions pour
le calcul récursif de la somme des éléments d’un tableau comptant 8 éléments. On
constate que la structure de cet arbre est elle aussi semblable à la structure du
Concepts de base 56

graphe précédent, mais renversée et avec moins de détails : on indique uniquement


les dépendances descendantes entre les activations de fonctions, les dépendances
ascendantes — pour «remonter» le résultat — étant implicites : une dépendance
indique donc un appel de la fonction et ensuite la réception du résultat retourné.
Dans ce qui suit, nous utiliserons souvent cette forme d’arbre d’activations pour
illustrer le comportement d’algorithmes récursifs.
Concepts de base 57

3.5 Indépendance entre threads


• L’ensemble de lecture (read set) d’une partie de programme est l’ensemble
des variables lues, mais non modifiées, par cette partie de programme.

• L’ensemble d’écriture (write set) d’une partie de programme est l’ensemble


des variables modifiées par cette partie de programme.

• Deux parties de programme sont indépendantes si l’ensemble d’écriture de


chaque partie est indépendante (l’intersection est vide) tant de l’ensemble de
lecture que de celui d’écriture de l’autre partie.

Plus formellement :

• Soit L(P ) l’ensemble de lecture de P :

L(P ) = {x | x est lue mais non modifiée par P }

• Soit E(P ) l’ensemble d’écriture de P :

E(P ) = {x | x est modifiée par P }

• Soit P1 et P2 deux parties de programme. Ces deux parties sont indépendantes


si et seulement si les trois conditions suivantes sont satisfaites :

L(P1 ) ∩ E(P2 ) = {}
L(P2 ) ∩ E(P1 ) = {}
E(P1 ) ∩ E(P2 ) = {}

En d’autres mots, P1 et P2 sont indépendantes si (i) chaque partie ne modifie


que des variables qui ne sont pas lues par l’autre et (ii) qu’elles écrivent dans
des variables différentes.
Concepts de base 58

3.6 Concurrence vs. parallélisme (bis) : Exemples


Ruby/MRI vs. JRuby
Les exemples qui suivent visent à illustrer la différence entre «concurrence» et
«parallélisme». Rappelons la caractérisation de R. Pike présentée plus haut :

In programming, concurrency is the composition of independently execut-


ing processes, while parallelism is the simultaneous execution of (possibly
related) computations. Concurrency is about dealing with lots of things
at once. Parallelism is about doing lots of things at once.
Source : Rob Pike, http: // blog. golang. org/ concurrency-is-not-parallelism

Dans le contexte spécifique de Ruby, une autre caractérisation intéressante est


celle présentée par E. Quran :

Ruby concurrency is when two tasks can start, run, and complete in
overlapping time periods. It doesn’t necessarily mean, though, that they’ll
ever both be running at the same instant (e.g., multiple threads on a
single-core machine). In contrast, parallelism is when two tasks liter-
ally run at the same time (e.g., multiple threads on a multicore proces-
sor).
[. . . ]
Ruby concurrency without parallelism can still be very useful, though, for
tasks that are IO-heavy6 (e.g., tasks that need to frequently wait on the
network).
Source : https: // www. toptal. com/ ruby/ ruby-concurrency-and-parallelism-a-practical-primer

Dans ce qui suit, nous allons présenter un problème avec parallélisme dont le
programme est CPU-bound, puis un problème avec concurrence dont le programme
est IO-bound. Dans ce dernier cas, nous verrons que même si Ruby/MRuby ne
permet pas le «vrai» parallélisme, un programme concurrent peut quand même
s’exécuter plus rapidement qu’un programme séquentiel.

6
IO-bound.
Concepts de base 59

Un problème avec parallélisme


Le programme Ruby 3.8 présente deux versions d’une fonction pour faire la «somme»
des éléments d’un tableau ; plus précisément, la fonction effectue la somme des élé-
ments mis au carré. . . simplement pour «augmenter» un peu le temps d’exécution.
La version parallèle décompose le tableau en 10 tranches, chacune traitée par
un Thread indépendant. Si on examine les temps requis pour diverses exécutions
— figure 3.16, colonne real et colonne avec le nombre en rouge — on constate
qu’avec Ruby/MRI, il n’y a aucune accélération, alors qu’on obtient une accéléra-
tion avec JRuby. Pour une raison technique — GIL = Global Interpreter Lock —
Ruby/MRI ne permet pas l’exécution parallèle /
Un problème avec concurrence
Le programme Ruby 3.9 présente deux versions d’une fonction pour lire et anal-
yser des URIs, donc faire des accès au Web. La version concurrente génère un
Thread pour chaque URI. Si on examine les temps requis pour diverses exécutions
— figure 3.17, colonne real et colonne avec le nombre en bleu — on constate
maintenant qu’on obtient une accélération tant dans la version Ruby/MRI que dans
la version JRuby , Bien que Ruby/MRI ne supporte pas l’exécution parallèle,
Ruby/MRI supporte quand même l’exécution concurrente — ici, les entrées/sorties
exécutées de façon asynchrone et concurrente.
Concepts de base 60

Programme Ruby 3.8 Calcul de la «somme» des éléments d’un tableau.


def somme_seq ( a )
(0... a . size ). reduce ( 0 ) { | somme , k | somme + a [ k ]**2.0 }
end

def somme_threads ( a )
def bornes_tranche ( k , n , nb_threads )
b_inf = k * n / nb_threads
b_sup = ( k + 1) * n / nb_threads - 1
b_inf .. b_sup
end

def sommation_seq ( a , bornes )


bornes . reduce (0) { | somme , k | somme + a [ k ]**2.0 }
end

nb_threads = 10
threads = (0... nb_threads ). collect do | k |
Thread . new { sommation_seq ( a , bornes_tranche (k , a . size , nb_threads ) ) }
end

threads . map (&: value ). reduce (&:+)


end

a = Array . new ( 1 _000_000 ) { rand }

Benchmark . bmbm do | bm |
bm . report ( ’ sequentiel ’ ) do
somme_seq ( a )
end

bm . report ( ’ avec threads ’ ) do


somme_threads ( a )
end
end
Concepts de base 61

Programme Ruby 3.9 Lecture et analyse d’une liste d’URIs.


COURS = [ ’ INF3135 ’ , ’ INF3140 ’ , ’ INF4110 ’ , ’ INF4170 ’ , ’ INF5171 ’ ,
’ INF600A ’ , ’ INF7440 ’ , ’ INF8541 ’ , ’ MGL7460 ’ , ’ MGL7160 ’]

def titre_du_cours ( cours )


uri = " http :// www . labunix . uqam . ca /~ tremblay /#{ cours }/ index . html "
titre = open ( uri ) do | page |
page . detect { | ligne | / TITLE / =~ ligne }
end
if titre && m = / < TITLE >(.*) <\/ TITLE >/. match ( titre )
m [1]
else
’ INCONNU ’
end
end

def titre_du_cours_seq
COURS . collect { | cours | titre_du_cours ( cours ) }
end

def titre_du_cours_threads
threads = COURS . collect do | cours |
Thread . new { titre_du_cours ( cours ) }
end
threads . map (&: value )
end

Benchmark . bmbm do | bm |
bm . report ( ’ sequentiel ’ ) do
titre_du_cours_seq
end

bm . report ( ’ avec threads ’ ) do


titre_du_cours_threads
end
end
Concepts de base 62

RESULTATS AVEC MRI Accélération


---------------------------------------------------------
user system total real
sequentiel 0.250000 0.000000 0.250000 ( 0.252342)
avec threads 0.240000 0.000000 0.240000 ( 0.242644) 1.04

user system total real


sequentiel 0.270000 0.000000 0.270000 ( 0.264436)
avec threads 0.240000 0.000000 0.240000 ( 0.241311) 1.10

user system total real


sequentiel 0.260000 0.000000 0.260000 ( 0.258922)
avec threads 0.240000 0.000000 0.240000 ( 0.246041) 1.05

user system total real


sequentiel 0.260000 0.000000 0.260000 ( 0.256229)
avec threads 0.240000 0.000000 0.240000 ( 0.241752) 1.06

RESULTATS AVEC JRUBY Accélération


---------------------------------------------------------
user system total real
sequentiel 3.390000 0.260000 3.650000 ( 0.322000)
avec threads 0.760000 0.020000 0.780000 ( 0.103000) 3.13

user system total real


sequentiel 3.270000 0.220000 3.490000 ( 0.306000)
avec threads 1.390000 0.030000 1.420000 ( 0.194000) 1.58

user system total real


sequentiel 3.330000 0.260000 3.590000 ( 0.334000)
avec threads 1.300000 0.020000 1.320000 ( 0.126000) 2.65

user system total real


sequentiel 3.190000 0.320000 3.510000 ( 0.315000)
avec threads 1.470000 0.010000 1.480000 ( 0.167000) 1.89

Figure 3.16: Temps pour diverses exécutions pour la «somme» des éléments d’un
tableau. (Exécution sur japet.labunix.uqam.ca)
Concepts de base 63

RESULTATS AVEC MRI Accélération


---------------------------------------------------------
user system total real
sequentiel 0.010000 0.000000 0.010000 ( 0.094572)
avec threads 0.020000 0.010000 0.030000 ( 0.017683) 5.35

user system total real


sequentiel 0.020000 0.000000 0.020000 ( 0.104718)
avec threads 0.010000 0.010000 0.020000 ( 0.016807) 6.23

user system total real


sequentiel 0.010000 0.010000 0.020000 ( 0.104398)
avec threads 0.020000 0.000000 0.020000 ( 0.016625) 6.28

user system total real


sequentiel 0.000000 0.010000 0.010000 ( 0.094736)
avec threads 0.010000 0.010000 0.020000 ( 0.020132) 4.71

RESULTATS AVEC JRUBY Accélération


---------------------------------------------------------
user system total real
sequentiel 0.520000 0.030000 0.550000 ( 0.124000)
avec threads 0.180000 0.010000 0.190000 ( 0.021000) 5.90

user system total real


sequentiel 0.520000 0.040000 0.560000 ( 0.136000)
avec threads 0.190000 0.010000 0.200000 ( 0.020000) 6.80

user system total real


sequentiel 0.580000 0.060000 0.640000 ( 0.148000)
avec threads 0.210000 0.020000 0.230000 ( 0.026000) 5.69

user system total real


sequentiel 0.510000 0.030000 0.540000 ( 0.114000)
avec threads 0.200000 0.020000 0.220000 ( 0.019000) 6.00

Figure 3.17: Temps pour diverses exécutions pour la lecture et l’analyse d’une liste
d’URIs. (Exécution sur japet.labunix.uqam.ca)
Concepts de base 64

3.7 Exercice : Interactions entre threads et tableaux


dynamiques

# !/ usr / bin / env ruby


#
# Petit programme illustrant certaines
# i n t e r a c t i o n s entre t h r ea d s et r e a l l o c a t i o n
# d y n a m i q u e de la taille d ’ un t a b l e a u .
#

NB = 20

loop do
a = Array . new

futures = []
(0... NB ). each do | i |
futures << PRuby . future { a [ i ] = 0 }
end

futures . map (&: value )

puts a . reduce (&:+)


end

Exercice 3.5: Qu’est-ce qui sera imprimé par ce programme?


Partie II

Introduction au langage Ruby

65
Chapitre 4

Introduction au langage Ruby par


des exemples

66
Ruby 67

4.1 Introduction : Pourquoi Ruby?


Le langage Ruby a été conçu, au milieu des années 90, par Yukihiro Matsumoto, un
programmeur Japonais. Son objectif était d’avoir un langage qui soit « plaisant» à
utiliser :

Ruby is “made for developer happiness”!


Y. Matsumoto

Y. Matsumoto s’est inspiré de plusieurs langages de programmation : Perl [WCS96]


(pour le traitement de texte et les expressions régulières) Smalltalk [Gol89] (pour ses
blocs et pour son approche orientée objet «pure» où tout est objet), CLU [LG86]
(pour ses itérateurs), Lisp [Ste84] (pour ses fonctions d’ordre supérieur). La fig-
ure 4.1 présente un arbre généalogique de Ruby. Quant au tableau 4.1, il présente
les «ancêtres» de Ruby, avec les principales caractéristiques héritées de ces ancêtres.

Langage Année Caractéristiques


Lisp 1958 approche fonctionnelle
métaprogrammation
CLU 1974 itérateurs

Smalltalk 1980 langage objet pur, blocs de code


GUI, sUnit
Eiffel 1986 Uniform Access Principle

Perl 1987 expressions régulières et pattern


matching
Ruby 1993

Tableau 4.1: Les ancêtres de Ruby.

Ruby a été popularisé notamment par le développement de Ruby on Rails [Dix11,


Har13, Lew15, RTH13], un framework pour la mise en oeuvre et le déploiement
d’applications Web.
Ruby 68

Figure 4.1: Arbre généalogique de divers langages de programmation, incluant Ruby.


Source: https://www.madetech.com/blog/pros-and-cons-of-ruby-on-rails.
Ruby 69

Figure 4.2: Les 10 premières positions du palmarès «The 2016 Top Programming
Languages» (IEEE Spectrum).

Selon une enquête faite par la revue IEEE Spectrum en 2016,1 Ruby est parmi
les 10 langages de programmation les plus utilisés : voir Figure 4.2.
Note : «Rankings are created by weighting and combining 12 metrics from 10
sources.»

Philosophie de Ruby
Ruby, comme Perl et Python [Bla04], est un langage à typage dynamique — un
langage dit de «script» — avec une syntaxe flexible (on verra comment plus loin) :
Ruby. . . est un langage open-source dynamique qui met l’accent sur la
simplicité et la productivité. Sa syntaxe élégante en facilite la lecture et
l’écriture. https: // www. ruby-lang. org/ fr/

Dixit Yukihiro Matsumoto, le concepteur de Ruby : Ruby inherited the Perl


philosophy of having more than one way to do the same thing. [. . . ] I want to
make Ruby users free. I want to give them the freedom to choose. People are
different. People choose different criteria. But if there is a better way among
many alternatives, I want to encourage that way by making it comfortable. So
that’s what I’ve tried to do.
Source : http://www.artima.com/intv/rubyP.html
1
http://spectrum.ieee.org/static/interactive-the-top-programming-languages-2016
Ruby 70

Mises en oeuvre de Ruby


Il existe plusieurs mises en oeuvre de Ruby, par exemple, MRI (Matz’s Ruby In-
terpreter, parfois appelé CRuby car sa mise en oeuvre est en langage C), Rubinius
(mise en oeuvre. . . en Ruby), JRuby (mise en oeuvre en Java sur la JVM = Java
Virtual Machine).
Dans le cadre du cours, c’est cette dernière mise en oeuvre que nous allons
utiliser, car c’est celle qui offre le support le plus intéressant pour la programma-
tion parallèle, grâce à son utilisation des threads de la JVM et en donnant accès à
plusieurs bibliothèques Java de programmation concurrente et parallèle.
Dans ce qui suit, nous allons introduire le langage Ruby par l’intermédiaire de
divers exemples. Nous n’introduirons que les éléments clés du langage requis pour
comprendre les exemples de programmation parallèle. Des éléments additionnels du
langage pourront aussi être introduits par la suite, au besoin.

Remarque : L’annexe 4.A décrit comment procéder pour installer JRuby.


Ruby 71

$ rvm list known


# MRI Rubies
[ruby-]1.8.6[-p420]
[ruby-]1.8.7[-head] # security released on head
[ruby-]1.9.1[-p431]
[ruby-]1.9.2[-p330]
[ruby-]1.9.3[-p551]
[ruby-]2.0.0[-p643]
[ruby-]2.1.4
[ruby-]2.1[.5] # GoRuby
[ruby-]2.2[.1] goruby
[ruby-]2.2-head
# Topaz
ruby-head topaz

# JRuby # MagLev
jruby-1.6.8 maglev[-head]
maglev-1.0.0
jruby[-1.7.19]
jruby-head # Mac OS X Snow Leopard Or Newer
jruby-9.0.0.0.pre1 macruby-0.10
macruby-0.11
# Rubinius macruby[-0.12]
macruby-nightly
rbx-1.4.3
macruby-head
rbx-2.4.1
rbx[-2.5.2] # IronRuby
rbx-head ironruby[-1.1.3]
ironruby-head
# Opal
opal

# Minimalistic ruby implementation - ISO 30170:2012


mruby[-head]

# Ruby Enterprise Edition


ree-1.8.6
ree[-1.8.7][-2012.02]
Figure 4.3: Les mises en oeuvre de Ruby disponibles par l’intermédiaire de rvm
(février 2016) — voir annexe 4.A .
Ruby 72

Figure 4.4: Quelques organisations qui utilisent JRuby. Source : «JRuby 9000 Is Out;
Now What?, T. Enebo and C. Nutter, RubyConf 2015, https://www.youtube.com/watch?v=
KifjmbSHHs0
Ruby 73

4.2 Compilation et exécution de programmes Ruby

Exemple Ruby 4.1 Deux versions d’un programme «Hello world!».


$ cat hello0 . rb
puts ’ Bonjour le monde ! ’

$ ruby hello0 . rb
Bonjour le monde !

------------------------------------

$ cat hello1 . rb
# !/ usr / bin / env ruby

puts ’ Bonjour le monde ! ’

$ ls -l hello1 . rb
- rwxr - xr - x . 1 tremblay tremblay 46 26 jun 09:52 hello1 . rb *

$ ./ hello1 . rb
Bonjour le monde !

Remarques et explications pour l’exemple Ruby 4.1 :


• L’instruction puts (putstring) affiche la chaîne indiquée en argument sur la
sortie standard (STDOUT) et ajoute un saut de ligne — on peut utiliser «print»
pour ne pas ajouter automatiquement un saut de ligne.
• Ruby étant un langage dynamique — on dit aussi un «langage de script» —
la compilation et l’exécution se font à l’aide d’une seule et même commande :
– À l’aide d’un appel explicite à la commande ruby : «ruby hello0.rb».
– À l’aide d’un appel implicite à la commande ruby : «./hello1.rb», la
première ligne (débutant par «#!») de hello1.rb indiquant que le script
doit être compilé et exécuté avec ruby.
• Bien qu’on parle souvent d’un processus «d’interprétation», les versions plus
récentes de Ruby utilisent un compilateur : une première passe analyse et com-
pile le programme pour générer du code-octet, puis ce code-octet est exécuté
par la machine virtuelle de Ruby (ou par la JVM dans le cas de JRuby).
Ruby 74

4.3 irb : Le shell interactif Ruby

Exemple Ruby 4.2 irb, le shell interactif de Ruby.


$ irb --prompt=simple
>> 10
= > 10

>> 2 + 4
=> 6

>> puts ’ Bonjour le monde ! ’


Bonjour le monde !
= > nil

>> r = puts ’ Bonjour le monde ! ’


Bonjour le monde !
= > nil
>> r
= > nil

>> puts ( ’ Bonjour le monde ! ’ )


Bonjour le monde !
= > nil

>> STDOUT . puts ( ’ Bonjour le monde ! ’ )


Bonjour le monde !
= > nil

>> STDERR . puts ( ’ Bonjour le monde ! ’ )


Bonjour le monde !
= > nil

>> STDIN . puts ( ’ Bonjour le monde ! ’ )


IOError : not opened for writing
from org / jruby / RubyIO . java :1407:in ’ write ‘
[...]
from / home / tremblay /. rvm / rubies / jruby -1.7.16.1/ bin / irb :13: in
’( root ) ’
Ruby 75

>> # _ denote la valeur de la derniere expression evaluee .

>> 8 * 100 / 2
= > 400

>> _ + _
= > 800

>> _ / 3
= > 266

>> _ / 3.0
= > 88.66666666666667

# On peut creer une nouvelle " session " ( interne ) qui modifie self ,
# l ’ objet courant .
>> irb [10 , 20]

>> self . class


= > Array

>> self
= > [10 , 20]

>> size
=> 2

>> irb " abcde "


>> self
= > " abcde "

>> ^ D
= > #< IRB :: Irb : @context =#< IRB :: Context :0 x0000000170a660 > ,
@signal_status =: IN_EVAL , @scanner =#< RubyLex :0 x0000000191a7c0 > >

>> self
= > [10 , 20]
Ruby 76

Remarques et explications pour l’exemple Ruby 4.2 :


• Une autre façon d’exécuter du code Ruby est d’utiliser irb — interactive Ruby.
• Le processus d’évaluation d’irb — le REPL = Read-Eval-Print-Loop — procède
comme suit :
– À l’invite de commande (prompt) indiquée par «>> », on entre une ex-
pression — par ex., «>> 2 + 4».2
– L’expression est évaluée.
– Le résultat retourné par l’expression est affiché après le symbole «=>» —
par ex., «=> 6».
• Le premier exemple de puts montre que si l’expression évaluée affiche quelque
chose sur STDOUT, alors cela s’affiche avant que le résultat retourné par l’expres-
sion ne soit affiché. On note aussi que le résultat retourné par un appel à puts
est nil.
• Les autres exemples illustrent ce qui suit :
– Dans un appel de méthode, les parenthèses — à moins qu’il n’y ait ambi-
guité à cause de la précédence des opérateurs : voir plus bas — peuvent
être omises. Donc, un appel tel que «puts ’xxx’» est équivalent à l’appel
«puts( ’xxx’ )».
– Un appel direct à puts est équivalent à un appel de puts sur STDOUT,
la constante qui dénote le flux standard de sortie. Lors d’une session
interactive irb, tant STDOUT que STDERR sont associés à l’écran. Quant
au flux d’entrée STDIN, il est aussi disponible et est associé par défaut
au clavier, donc il ne peut évidemment pas être utilisé pour effectuer des
écritures.
• L’identificateur «_» est toujours associé à la valeur produite par la dernière
expression évaluée.
• On peut créer une nouvelle session, qui modifie l’objet courant, i.e., self. Ceci
permet donc d’examiner plus facilement un objet et ses propriétés.
On termine une session avec «Ctrl-D».
Dans plusieurs des exemples qui suivent, ce sont les résultats produits avec irb
qui seront affichés, bien que dans certains cas les détails affichés seront quelque peu
simplifiés pour faciliter la lecture des exemples.
2
Plus précisément, ce prompt est obtenu en utilisant la commande «irb --prompt=simple».
Si cette option est omise, alors le prompt affiche des informations additionnelles, par exemple,
«jruby-1.7.16.1 :001 >» — la version de Ruby utilisée et un numéro pour l’expression.
Ruby 77

4.4 Tableaux

Exemple Ruby 4.3 Les tableaux et leurs opérations de base.


>> # Valeurs litterales , indexation et taille .
? > a = [10 , 20 , 30]
= > [10 , 20 , 30]

>> a [0]
= > 10

>> a [2]
= > 30

>> a [2] = 55
= > 55

>> a
= > [10 , 20 , 55]

>> a . size
=> 3

? > # Valeur nil par defaut et extension de la taille .


? > a [6]
= > nil

>> a . size
=> 3

>> a [5] = 88
= > 88

>> a . size
= > ??

>> a
= > ??
Ruby 78

? > # Acces au ’ dernier ’ element .


? > a [ a . size -1]
= > 88

>> a [ -1]
= > 88

Remarques et explications pour l’exemple Ruby 4.3 :

• Un commentaire débute par «#» et se termine à la fin de la ligne. Une ligne


ne contenant qu’un commentaire n’a donc pas d’expression à évaluer et, dans
irb, l’entrée se continue à la ligne suivante avec le prompt de continuation
(«?> »).
Il est possible d’indiquer un commentaire formé par un bloc de lignes — bien
que ce soit utilisé assez peu fréquemment sauf pour certaines formes de docu-
mentation (e.g., RDoc) :
= begin
Blah blah
...
= end

• L’indice du premier élément d’un tableau, comme en C et en Java, est 0.

• Un tableau a est automatiquement étendu à la taille appropriée si on affecte à


un indice plus grand ou égal à a.size. Les valeurs non explicitement définies
sont alors égales à nil.

• L’indice du dernier élément d’un tableau a est a.size-1. On peut aussi ac-
cèder au dernier élément avec l’indice -1, à l’avant-dernier avec l’indice -2,
etc.
Ruby 79

Exemple Ruby 4.4 Les tableaux et leurs opérations de base (suite 1).
? > # Tableaux heterogenes .
?> a
= > [10 , 20 , 55 , nil , nil , 88]

>> a [8] = ’ abc ’


= > " abc "

>> a
= > [10 , 20 , 55 , nil , nil , 88 , nil , nil , " abc " ]

? > # Ajout d ’ elements .


? > a = []
= > []

>> a << 12
= > [12]

>> a << ’ abc ’ << [2.7 , 2.8]


= > [12 , " abc " , [2.7 , 2.8]]

? > # Creation de tableaux avec valeurs initiales .


? > b = Array . new (3) { 10 }
= > [10 , 10 , 10]

>> d = Array . new (4)


= > [ nil , nil , nil , nil ]

Remarques et explications pour l’exemple Ruby 4.4 :

• Les tableaux sont hétérogènes, i.e., ils peuvent contenir des éléments de types
variés.
Ruby 80

• Il existe de nombreuses opérations pour ajouter ou retirer des éléments d’un


tableau, par exemple, push, pop, shift, unshift, etc.3

• Une opération fréquemment utilisée est push, qui a comme alias «<<».

• L’opération new permet de créer un tableau d’une certaine taille de départ


et permet aussi de spécifier, de façon optionnelle, une valeur initiale pour les
différents éléments du tableau.

Exemple Ruby 4.5 Les tableaux et leurs opérations de base (suite 2).
? > # Tranches de tableaux .
? > a = [10 , 20 , 30 , 40 , 50]
= > [10 , 20 , 30 , 40 , 50]

>> a [0..2]
= > [10 , 20 , 30]

>> a [3..3]
= > ??

>> a [1.. -1]


= > ??

>> a [7..7]
= > ??

Remarques et explications pour l’exemple Ruby 4.5 :

• On peut obtenir une tranche d’un tableau en spécifiant comme indice un


Range. Un Range avec «..» est inclusif — par ex., b_inf..b_sup — donc
inclut tant la borne inférieure que la borne supérieure. Par contre, un Range
avec «...» est exclusif, i.e., inclut la borne inférieure mais exclut la borne
supérieure.

3
Voir http://ruby-doc.org/core-2.2.0/Array.html pour la liste complète.
Ruby 81

? > # Intervalles inclusifs vs . exclusifs


>> a
= > [10 , 20 , 30 , 40 , 50]

>> a [1..3]
= > [20 , 30 , 40]

>> a [1...3]
= > [20 , 30]

>> a [1.. a . size -1]


= > [20 , 30 , 40 , 50]

>> a [1... a . size ]


= > [20 , 30 , 40 , 50]
Ruby 82

4.5 Chaînes de caractères

Exemple Ruby 4.6 Les chaînes de caractères et leurs opérations de base.


>> # String semblable a Array .
? > s1 = ’ abc ’
= > " abc "

>> s1 . size
=> 3

>> s1 [0..1] # Retourne String .


= > " ab "

>> s1 [2] # Retourne String aussi !


=> "c"

? > # Concatenation vs . ajout .


? > s1 + ’ def ’
= > " abcdef "

>> s1
= > " abc "

>> s1 << ’ def ’


= > " abcdef "

>> s1
= > " abcdef "

Remarques et explications pour l’exemple Ruby 4.6 :


• Un String est, par certaines opérations, semblable à un Array, notamment,
on peut l’indexer pour obtenir un élément ou une sous-chaîne.
• L’indexation d’un String. . . retourne un String,4 que l’indice soit un Range
ou un Fixnum.
4
Depuis Ruby 1.9.
Ruby 83

• L’opération «+» concatène deux chaînes pour produire une nouvelle chaîne
(opération fonctionnelle, immuable) alors que l’opérateur «<<» ajoute (append)
des caractères à la fin d’une chaîne existante (opération impérative, mutable).

Exemple Ruby 4.7 Les chaînes de caractères et leurs opérations de base (suite).
>> # Egalite de valeur * sans * partage de reference .
? > a , b = ’ abc ’ , ’ abc ’
= > [ " abc " , " abc " ]

>> a == b
= > true

>> a . equal ? b
= > false

>> a [0] = ’X ’
=> "X"

>> a
= > " Xbc "

>> b
= > " abc "

Remarques et explications pour l’exemple Ruby 4.7 :

• Ruby possède plusieurs opérations de comparaison d’égalité. Les deux pre-


mières sont les suivantes :
– «==» : Comparaison de valeur. C’est généralement cette opération que
l’on spécifie dans les classes que l’on définit nous-mêmes.
– «equal?» : Comparaison d’identité, i.e., est-ce que les deux éléments
comparés dénotent le même objet (le même pointeur, la même référence)?

Note : La signification de ces opérateurs est l’inverse de celle de Java, où «==»


est la comparaison d’identité alors que equals est la comparaison de valeur.

• En Ruby, il est considéré de bon style qu’une méthode qui retourne un booléen
se termine par «?», par exemple, equal?, empty?, nil?, block_given?, etc.
Ruby 84

? > # Egalite de valeur * avec * partage de reference .


? > a = b = ’ abc ’
= > " abc "

>> a == b
= > true

>> a . equal ? b
= > true

>> a [0] = ’X ’
=> "X"

>> a
= > " Xbc "

>> b
= > " Xbc "

Exemple Ruby 4.8 Interpolation d’une expression dans une chaîne.


>> # Interpolation d ’ une expression dans une chaine .
? > x = 123
= > 123

? > " abc \"#{ x }\" def "


= > " abc \"123\" def "

? > " abc ’#{10 * x + 1} ’ def "


= > " abc ’1231 ’ def "

? > " abc #{ x > 0 ? ’++ ’ : 0} def "


= > " abc ++ def "

? > # String definie avec ’... ’ = > pas d ’ interpolation .


? > ’ abc "#{ x }" def ’
= > " abc \"\#{ x }\" def "
Ruby 85

Remarques et explications pour l’exemple Ruby 4.8 :

• Une chaîne produite avec les doubles guillemets permet d’interpoler une ou
des expressions. Dans une telle chaîne, «#{...}» indique alors une expression
qui doit être évaluée — on dit aussi interpolée. Si cette expression produit un
résultat qui n’est pas une chaîne, alors la méthode to_s sera implicitement
appelée — voir plus bas.

• On peut aussi définir une chaîne avec de simples guillemets, mais dans ce cas
aucune interpolation n’est effectuée (chaine textuelle).

Exemple Ruby 4.9 Opérations split et join.


# Split decompose une chaine en sous - chaines
# en fonction du << motif > > specifie en argument .

>> s = " abc \ ndef \ nghi \ n "


= > " abc \ ndef \ nghi \ n "

>> s . split ( " \ n " ) # Un cas typique !


= > [ " abc " , " def " , " ghi " ]

>> s . split ( " def " )


= > [ " abc \ n " , " \ nghi \ n " ]

>> s . split ( " a " )


= > [ " " , " bc \ ndef \ nghi \ n " ]

>> s . split (/\ w {3}/) # \ w = [a - zA - Z0 -9 _ ]


=> ["", "\n", "\n", "\n"]
Ruby 86

# Join combine un tableau de sous - chaines


# en une chaine unique .

>> s
= > " abc \ ndef \ nghi \ n "

>> r = s . split ( " \ n " )


= > [ " abc " , " def " , " ghi " ]

>> r . join ( " + " )


= > " abc + def + ghi "

>> r . join ( " \ n " ) # Donc : s . split ("\ n "). join ("\ n ") != s
= > " abc \ ndef \ nghi "

>> []. join ( " ; " )


=> ""

>> [ ’ abc ’ ]. join ( " ; " )


= > " abc "

>> [ ’ abc ’ , ’ def ’ ]. join ( " ; " )


= > " abc ; def "

Remarques et explications pour l’exemple Ruby 4.9 :

• La méthode split décompose une chaine pour produire un tableau de sous-


chaines, et ce en utilisant le séparateur indiqué.

• Le séparateur peut être un caractère, une chaine ou une expression régulière.


Les séparateurs ne font pas partie des sous-chaines retournées par split.

• Une sous-chaine peut être la chaine vide ("") si la chaine continent deux
séparateurs consécutifs ou si la chaine débute ou se termine par un séparateur.

• La méthode join reçoit un tableau de chaines et les combine en une seule


chaine en utilisant la chaine indiquée.
Ruby 87

4.6 Symboles

Exemple Ruby 4.10 Les symboles.


>> # Symbole = " sorte " de chaine * unique et immuable *.
>> : abc
= > : abc

>> : abc . class


= > Symbol

>> : abc . to_s


= > " abc "

>> puts : abc


abc
= > nil

>> : abc [2]


=> "c"

>> : abc [2] = " x "


NoMethodError : undefined method ’ []= ’ for : abc : Symbol
from ( irb ):4
from / home / tremblay /. rvm / rubies / ruby -2.1.4/ bin / irb :11:in ’ < main > ’

>> " abc " . to_sym


= > : abc

>> " abc def .!#% " . to_sym


= > : " abc def .!#% "

Remarques et explications pour l’exemple Ruby 4.10 :

• Un symbole — classe Symbol — est une représentation unique d’un identifica-


teur.

• Un identificateur débutant par «:» est un symbole.

• Un symbole est une sorte de chaîne mais unique et immuable.


Ruby 88

? > # Possede un numero d ’ identification unique .


>> : a
=> :a

>> : a . object_id
= > 365128

>> " a " . object_id


= > 11000000

>> " a " . object_id


= > 10996280

>> : a . object_id
= > 365128

>> " a " . to_sym . object_id


= > 365128

Ainsi, bien que deux chaînes peuvent avoir la même valeur tout en étant
distinctes l’une de l’autre — ch1 == ch2 mais !(ch1.equal? ch2) — ce n’est
pas le cas avec les symboles — sym1 == sym2 implique sym1.equal? sym2.

• On peut obtenir le symbole associé à une chaîne arbitraire à l’aide de la mé-


thode to_sym.

• On peut observer l’unicité d’un symbole en obtenant son object_id — un


entier qui identifie de façon unique n’importe quel objet (≈ l’adresse de l’objet
en mémoire!).
Deux chaines peuvent être égales mais ne pas être le même objet, donc avoir
deux object_id distincts. Par contre, chaque référence à un symbole retourne
toujours le même object_id.

• Les symboles sont souvent utilisés comme clés pour les hashes, de même que
pour les keyword arguments — voir plus bas.
Ruby 89

? > # Egalite de valeur vs . de reference .


?>
>> : abc == : abc
= > true

>> : abc . equal ? : abc


= > true

>> " abc " == " abc "


= > true

>> " abc " . equal ? " abc "


= > false

>> " abc " . to_sym == " abc " . to_sym


= > true

>> " abc " . to_sym . equal ? " abc " . to_sym
= > true
Ruby 90

4.7 Hashes

Exemple Ruby 4.11 Les hashes et leurs opérations de base.


>> # Definition d ’ un hash .
? > hash = { : abc = > 3 , : de = > 2 , : ghijk = > 5 }
= > {: abc = >3 , : de = >2 , : ghijk = >5}

? > # Principales proprietes .


? > hash . size
=> 3

>> hash . keys


= > [: abc , : de , : ghijk ]

>> hash . values


= > [3 , 2 , 5]

>> # Indexation .
? > hash [: abc ]
= > ??

>> hash [: de ]
= > ??

>> hash [ " de " ]


= > ??
Ruby 91

Exemple Ruby 4.12 Les hashes et leurs opérations de base (suite).


? > # Definition d ’ une nouvelle cle .
? > hash . include ? " de "
= > false

>> hash [ " de " ] = 55


= > 55

>> hash . include ? " de "


= > true

? > # Redefinition d ’ une cle existante .


? > hash [: abc ] = 2300
= > 2300

>> hash
= > {: abc = >2300 , : de = >2 , : ghijk = >5 , " de " = >55}

Exemple Ruby 4.13 Les hashes et leurs opérations de base (suite) : Création et
initialisation.
? > # Creation d ’ un Hash sans valeur par defaut .
? > h1 = {} # Idem : h1 = Hash . new
= > {}

>> h1 [: xyz ]
= > nil

>> # Creation d ’ un Hash avec valeur par defaut .


? > h2 = Hash . new ( 0 )
= > {}

>> h2 [: xyz ]
=> 0

>> h2 [: abc ] += 1
=> 1
Ruby 92

>> # Creation d ’ un Hash avec valeur par defaut .


# Attention : La valeur est * partagee *
# par toutes les cles !
? > h3 = Hash . new ( [] )
= > {}

>> p h3 [: x ] , h3 [: y ]
[]
[]
= > [[] , []]

>> h3 [: x ] << " abc "


= > [ " abc " ]
>> p h3 [: x ] , h3 [: y ]
[ " abc " ]
[ " abc " ]
= > [[ " abc " ] , [ " abc " ]]

>> # Creation d ’ un Hash avec valeur par defaut ,


# definie via un bloc pour avoir
# une nouvelle valeur a chaque fois .
>> h4 = Hash . new { |h , k | h [ k ] = [] }
= > {}

>> p h4 [: x ] , h4 [: y ]
[]
[]
= > [[] , []]

>> h4 [: x ] << " abc "


= > [ " abc " ]
>> p h4 [: x ] , h4 [: y ]
[ " abc " ]
[]
= > [[ " abc " ] , []]
Ruby 93

Remarques et explications pour les exemples Ruby 4.11–4.13 :

• Les hashs — classe Hash — sont aussi appelés dictionnaires, maps.

• Un hash peut être vu comme une forme généralisée de tableau, au sens où


l’index — la clé — est arbitraire et non pas un simple entier plus grand ou
égal à 0.
On peut aussi interpréter un hash comme une fonction associant des valeurs
(values = codomaine) à des clés (keys = domaine). Donc, étant donné une
clé, le hash retourne la valeur associée.

• La valeur associée à une clé peut être modifiée, et ce simplement en affectant


une valeur nouvelle, par exemple, «hash["de"] = 55». Un objet de classe
Hash est donc un objet mutable.

• Si aucune valeur par défaut n’a été spécifiée pour un hash, la valeur retournée
pour une clé non définie est nil. Par contre, au moment de la création du
hash, il est possible de spécifier la valeur qui doit être retournée par défaut,
i.e., la définition à retourner si la clé n’est pas explicitement définie.
Attention : Avec la forme simple de valeur initiale — Hash.new(v) — la
valeur v est la même pour toutes les clés. Si v est un tableau, il sera donc
partagé par toutes les clés — ce qui n’est généralement pas l’effet désiré.
Pour associer un tableau vide à chaque nouvelle clé, il faut plutôt utiliser
la forme avec un bloc, lequel reçoit en argument l’objet Hash (h) et la clé
nouvellement rencontrée (k).
Ruby 94

4.8 Expressions booléennes


Le point important à retenir pour comprendre les expressions booléennes :

• false et nil sont des valeurs «fausses»

• Toute autre valeur est «vraie».


Quelques exemples avec l’opérateur ternaire ?: — voir aussi plus bas :
>> false ? ’ oui ’ : ’ non ’
= > " non "

>> nil ? ’ oui ’ : ’ non ’


= > ’ non ’

>> 0 ? ’ oui ’ : ’ non ’


= > " oui "

>> ’ ’ ? ’ oui ’ : ’ non ’


( irb ):5: warning : string literal in condition
= > " oui "

>> nil . nil ? ? ’ nil ’ : ’ pas nil ’


= > " nil "
Ruby 95

Exemple Ruby 4.14 Les expressions booléennes.


>> # Toute valeur differente de false ou nil est vraie .
? > true ? ’ oui ’ : ’ non ’
= > " oui "

>> 0 ? ’ oui ’ : ’ non ’


= > " oui "

>> [] ? ’ oui ’ : ’ non ’


= > " oui "

? > # Seuls false et nil ne sont pas vraies .


? > false ? ’ oui ’ : ’ non ’
= > " non "

>> nil ? ’ oui ’ : ’ non ’


= > " non "

>> ! false ? ’ oui ’ : ’ non ’


= > " oui "

>> ! nil ? ’ oui ’ : ’ non ’


= > " oui "

? > # Seul nil est nil


? > 2. nil ? ? ’ nil ’ : ’ pas nil ’
= > " pas nil "

>> []. nil ? ? ’ nil ’ : ’ pas nil ’


= > " pas nil "

>> nil . nil ? ? ’ nil ’ : ’ pas nil ’


= > " nil "

Remarques et explications pour l’exemple Ruby 4.14 :

• En Ruby, toute valeur différente de false ou nil peut être interprétée comme
une valeur «vraie».

• Bien que nil et false soient tous deux faux, seul nil est nil?.
Ruby 96

Exemple Ruby 4.15 Les expressions booléennes (suite 1).


? > # Les expressions && et || sont court - circuitees .
? > true || (3 / 0) ? true : false
= > true

>> false && (3 / 0) ? true : false


= > false

? > false || (3 / 0) ? true : false


ZeroDivisionError : divided by 0
[...]

>> true && (3 / 0) ? true : false


ZeroDivisionError : divided by 0
[...]

Remarques et explications pour l’exemple Ruby 4.15 :

• Bien qu’il existe aussi des opérateurs and et or, on utilise plutôt les opérateurs
&& et ||.
Ces derniers opérateurs sont évalués en mode «court-circuité». En d’autres
mots, on évalue uniquement la portion d’expression nécessaire pour s’assurer
que le résultat soit vrai ou faux, selon l’opérateur utilisé — puisqu’on a que
«false && x == x» et que «true || x == x».
Ruby 97

Exemple Ruby 4.16 Les expressions booléennes (suite 2).


>? # L ’ operateur || retourne la premiere expression
# ’ non fausse ’, sinon retourne la derniere expression .
? > 2 || 3
= > ??

>> nil || false || 2 || false


= > ??

>> nil || false


= > ??

>> false || nil


= > ??

Exemple Ruby 4.17 Les expressions booléennes (suite 3).


# On peut utiliser ||= pour initialiser une variable ,
# sauf si elle est deja initialisee .

>> x
NameError : undefined local variable or method ’x ’ for main : Object
[...]

>> x ||= 3
=> 3
>> x
=> 3

>> x ||= 8
=> 3
>> x
=> 3

Remarques et explications pour les exemples Ruby 4.16–4.17 :


• On a dit que l’opérateur «||» était évalué de façon court-circuitée.
Ruby 98

Une autre façon d’interpréter son comportement est de dire qu’il retourne la
première valeur non fausse (différente de false ou de nil), ou sinon la dernière
valeur.
• L’opérateur «||=» est semblable, mais pas complètement, à l’opérateur «+=»
— et à de nombreux autres opérateurs — en ce qu’il dénote une forme abréviée
d’une expression binaire :5
x += 1 # x = x + 1
x /= 2 # x = x / 2

x ||= 1 # x || x = 1 ET non pas x = x || 1


x &&= 1 # x && x = 1 Et non pas x = x && 1

• On utilise souvent l’opérateur «||=» pour donner une valeur initiale à une
variable à la condition qu’elle ne soit pas déjà initialisée.

5
http://www.rubyinside.com/what-rubys-double-pipe-or-equals-really-does-5488.
html
Ruby 99

4.9 Définitions et appels de méthodes

Exemple Ruby 4.18 Définitions et appels de méthodes.


>> # Definition et appels de methode .
def add ( x , y )
x + y
end

>> add ( 2 , 3 )
=> 5
>> add 20 , 30 # Les parentheses sont optionnelles .
=> 50

>> # Resultat = derniere expression evaluee .


def abs ( x )
if x < 0 then -1 * x else x end
end

>> abs ( 3 )
=> 3
>> abs ( -3 )
=> 3

>> # On utilise return pour sortir ’ avant la fin ’.


def abs2 ( x )
return x if x >= 0

-x
end

>> abs2 ( 23 )
=> 23
>> abs2 ( -23 )
=> 23
Ruby 100

Remarques et explications pour l’exemple Ruby 4.18 :


• Une définition de méthode est introduite par le mot-clé def, suivi du nom de la
méthode, suivi du nom des paramètres, suivi d’une ou plusieurs instructions,
suivi du mot-clé end.
• Ruby étant un langage à typage dynamique, il n’est pas nécessaire — ni pos-
sible — de spécifier le type des paramètres ou du résultat d’une méthode.
• Une méthode peut être définie de façon plus «compacte» — bien que cela ne
soit pas recommandée — en séparant les éléments par «;». L’exemple qui suit
illustre donc qu’en Ruby, le «;» sert de séparateur (et non de terminateur,
comme en C ou Java) :
def add ( x , y ); x + y ; end

• Dans un if, le then n’est requis que si on utilise le if comme expression sur
une seule ligne — les règles de style de Ruby veulent qu’on omette le then si
le if est écrit sur plusieurs lignes.
• Le résultat retourné par une méthode est la dernière expression évaluée par
la méthode. Les méthodes add suivantes sont donc équivalentes — mais la
première est considérée d’un «meilleur style» :
def add ( x , y )
x + y
end

def add ( x , y )
return x + y
end

Le style Ruby veut que return soit utilisée seulement pour retourner un ré-
sultat «au milieu» d’une série d’instructions, i.e., avant la dernière instruction
ou expression d’une méthode. La méthode abs2 illustre une telle utilisation.
• Remarque importante : Comme l’illustre l’un des exemples, les parenthèses
pour un appel de méthodes peuvent être omises. Par contre, si ces parenthèses
sont présentes, alors il ne doit pas y a avoir d’espaces entre le nom de la
méthode et les parenthèses, car dans ce dernier cas, les parenthèses pourraient
indiquer le début d’une expression complexe :
add ( 2 , 3 ) # OK ,
add 2 , 3 # OK ,
add ( 2 , 3 ) # Pas OK /
add (1+1) , (2+1) # OK ,
Ruby 101

Exemple Ruby 4.19 Appels de méthodes et envois de messages.


>? # Un operateur est une methode .
?> 2 + 3
=> 5

>> 2.+( 3 )
=> 5

>> 2.+ 3
=> 5

>? # Un appel de methode est un envoi de message .


>? 2.+( 3 )
=> 5

>> 2. send ( :+ , 3 )
=> 5

Remarques et explications pour l’exemple Ruby 4.19 :

• Les opérateurs (dits «infixes») pour les expressions sont des méthodes comme
les autres. Une expression telle que «2 + 3» est donc un appel à la méthode
«+» de l’objet «2» avec l’objet «3» comme argument.

• Un appel de méthode avec des arguments correspond à l’envoi d’un message


à un objet avec ces arguments.
Ruby 102

4.10 Structures de contrôle

Exemple Ruby 4.20 Structures de contrôles: if.


>> # Instruction conditionnelle classique .
def div ( x , y )
if y == 0
fail " Oops ! Division par zero :( "
else
x / y
end
end

>> div ( 12 , 3 )
=> 4

>> div ( 12 , 0 )
RuntimeError : Oops ! Division par zero :(
from ( irb ):4:in ’ div ’
[...]
from / home / tremblay /. rvm / rubies / jruby -1.7.16.1/ bin / irb :13:in ’( root ) ’

>> # Garde ( condition ) if associee a une instruction .


def div ( x , y )
fail " Oops ! Division par zero :( " if y == 0

x / y
end

>> div ( 12 , 3 )
=> 4

Remarques et explications pour l’exemple Ruby 4.20 :


• Dans une instruction if, bien que le mot-clé then puisse être indiqué, les règles
de style de Ruby veulent qu’on l’omette.

• Une instruction fail lance une exception, de type RuntimeError.


Ruby 103

• Tel qu’indiqué précédemment, le caractère «;» est utilisé seulement pour sé-
parer des instructions apparaissant sur une même ligne. Contrairement aux
langage C et Java, le «;» ne sert donc pas à terminer une instruction — un
saut de ligne suffit.
• Lorsqu’une instruction simple doit être exécutée de façon conditionnelle, il est
considéré de bon style d’utiliser une garde, donc :
instruction_simple if condition

... plutôt que ...

if condition
instruction_simple
end

Dans un tel cas, il est aussi suggéré de toujours utiliser une condition positive,
si nécessaire en utilisant unless. Exemple : on veut retourner le premier
élément d’un tableau a si un tel élément existe :
return a[0] unless a.empty?
... plutôt que ...
return a[0] if !a.empty?

Exemple Ruby 4.21 Structures de contrôles: while.


? > # Instruction while .
def pgcd ( a , b )
# On doit avoir a <= b .
return pgcd ( b , a ) if a > b

while b > 0
a, b = b, a % b
end

a
end

>> pgcd ( 12 , 8 )
=> 4
>> pgcd ( 80 , 120 )
=> 40
Ruby 104

Remarques et explications pour l’exemple Ruby 4.21 :

• Une instruction while s’exécute tant que la condition indiquée reste vraie (i.e.,
non false, non nil).

• On parle d’une affectation parallèle lorsque, du coté gauche de l’opérateur


d’affectation, on retrouve plusieurs variables séparées par des «,».
Voici comment, en Ruby, on peut interchanger le contenu de x et y sans utiliser
de variable temporaire :
x, y = y, x

On peut aussi utiliser de telles affectations pour «déconstruire» un tableau :


x , y , z = [10 , 20 , 30]
# x == 10 && y == 20 && z == 30

x , y = [10 , 20 , 30]
# x == 10 && y == 20

x , * y = [10 , 20 , 30]
# x == 10 && y == [20 , 30]
Ruby 105

Exemple Ruby 4.22 Structures de contrôles : Itération sur les index avec for et
each_index.
? > # Instruction for
def somme ( a )
total = 0
for i in 0... a . size
total += a [ i ]
end

total
end

>> somme ( [10 , 20 , 30] )


= > 60

? > # Iterateur each_index .


def somme ( a )
total = 0
a . each_index do | i |
total += a [ i ]
end

total
end

>> somme ( [10 , 20 , 30] )


= > 60
Ruby 106

Exemple Ruby 4.23 Structures de contrôles : Itération sur les éléments avec for
et each.
? > # Instruction for ( bis )
def somme ( a )
total = 0
for x in a
total += x
end

total
end

>> somme ( [10 , 20 , 30] )


= > 60

? > # Iterateur each .


def somme ( a )
total = 0
a . each do | x |
total += x
end

total
end

>> somme ( [10 , 20 , 30] )


= > 60

Remarques et explications pour les exemples Ruby 4.22–4.23 :

• Une expression telle que 0..n est un Range (intervalle) qui génère les valeurs
0, 1, . . . , n. Par contre, un Range tel que 0...n génère les valeurs 0, 1, . . . , n-1.
On parle donc de Range inclusif (..) vs. exclusif (...) — voir plus haut.
Une boucle telle que «for i in 0...a.size» permet donc traiter tous les
indices valides de a, donc 0, 1, . . . , a.size-1.

• En Ruby, l’utilisation de la boucle for est fortement déconseillée — son utili-


sation est considérée comme «de mauvais style» — et ce pour deux raisons :
Ruby 107

– La variable d’itération n’est pas strictement locale à la boucle, donc sa


valeur est modifiée par la boucle si elle existait déjà.
– Une boucle for est mise en oeuvre par un each, donc est moins efficace
— son utilisation ajoute un niveau additionnel d’indirection.

• La méthode each est la méthode générale et universelle pour l’itération : tous


les objets composites — les collections — définissent (ou à tout le moins de-
vraient définir) une méthode each qui permet de parcourir les éléments de la
collection. On verra des exemples ultérieurement.
Dans l’exemple 4.22, on utilise tout d’abord each_index, qui génére les dif-
férents indices de a. Dans ce cas, cela produit une solution semblable à celle
du for.
Par contre, dans l’exemple 4.23, on utilise plutôt each, qui génère directement
les différents éléments de a. C’est cette dernière solution qui est la solution
typique — plus «idiomatique» — en Ruby.
Ruby 108

4.11 Paramètres des méthodes

Exemple Ruby 4.24 Paramètres des méthodes : valeur par défaut et nombre
variable d’arguments.
? > # Argument optionnel et valeur par defaut .
def foo ( x , y = 40 )
x + y
end

>> foo ( 3 , 8 )
= > ??

>> foo ( 3 )
= > ??

>> # Nombre variable d ’ arguments .


def bar ( x , * args , y )
" bar ( #{ x } , #{ args } , #{ y } ) "
end

>> bar ( 1 , 2 , 3 , 4 , 5 )
= > ??

>> bar ( 1 , 2 )
= > ??

>> bar ( 23 )
??
??
??
??

Remarques et explications pour l’exemple Ruby 4.24 :


• Un ou des argument d’une méthode peuvent être omis si on spécifie une valeur
par défaut. Par contre, ces arguments optionnels doivent apparaître après les
arguments obligatoires.
Ruby 109

• Il est possible de transmettre un nombre variable d’arguments, ce qu’on in-


dique en préfixant le nom du paramètre avec «*». Dans la méthode, le nom
du paramètre, sans le «*», est alors un tableau formé des arguments reçus.

Exemple Ruby 4.25 Paramètres des méthodes : arguments par mots-clés (keyword
arguments).
>> # Arguments par mot - cles ( keyword arguments ).
def diviser ( numerateur : , denominateur : 1 )
numerateur / denominateur
end

>> diviser numerateur : 12 , denominateur : 3


=> 4

>> diviser denominateur : 3 , numerateur : 12


=> 4

>> diviser numerateur : 12


= > 12

>> diviser 10
ArgumentError : missing keyword : numerateur
from ( irb ):31
from / home / tremblay /. rvm / rubies / ruby -2.1.4/ bin / irb :11:in ’ < main > ’

Remarques et explications pour l’exemple Ruby 4.25 :

• Depuis Ruby 2.0,6 on peut définir des méthodes où les paramètres et arguments
sont définis par des mots-clés — keyword arguments — donc semblables à ce
qu’on retrouve en Smalltalk [Gol89].

• Les paramètres par mot-clés permettent de spécifier les arguments lors d’un
appel de méthode sans avoir à respecter un ordre strict quant à la position des
arguments — voir les deux premiers appels à diviser.

• On peut, ou non, spécifier une valeur par défaut pour les arguments par mots-
clés.
6
On peut aussi définir de telles méthodes en Ruby 1.9, mais pour ce faire il faut manipuler
explicitement un hash.
Ruby 110

? > # Argument par mot - cle .


def premier_index ( a , x , res_si_absent : nil )
a . each_index do | i |
return i if a [ i ] == x
end
res_si_absent
end

>> premier_index ( [10 , 20 , 10 , 20] , 10 )


=> 0
>> premier_index ( [10 , 20 , 10 , 20] , 88 )
=> nil
>> premier_index ( [10 , 20 , 10 , 20] , 88 , res_si_absent : -1 )
=> -1

• De tels paramètres sont souvent utiles pour les arguments optionnels, par
exemple, res_si_absent. La présence du mot-clé rend alors l’appel plus clair
quant au rôle de l’argument additionnel.
Ainsi, la méthode premier_index aurait pu être définie et utilisée comme suit,
mais le rôle de l’argument additionel aurait été moins clair :
def premier_index ( a , x , res_si_absent = nil )
...
end

premier_index ( [10 , 20 , 10 , 20] , 88 , -1 )


Ruby 111

Soit la méthode suivante :


def foo ( x , y , z = nil )
return x + y * z if z

x * y
end
Indiquez ce qui sera affiché par chacun des appels suivants :
# a.
puts foo ( 2 , 3 )

# b.
puts foo 2 , 3 , 5

# c.
puts foo ( " ab " , " cd " , 3 )

# d.
puts foo ( " ab " , " cd " )

Exercice 4.1: Définition et utilisation de méthodes diverses avec plusieurs sortes


d’arguments.
Ruby 112

Soit la méthode suivante :


def bar ( v = 0 , * xs )
m = v
xs . each do | x |
m = [m , x ]. max
end
m
end
Indiquez ce qui sera affiché par chacun des appels suivants :
# a.
puts bar

# b.
puts bar ( 123 )

# c.
puts bar ( 0 , 10 , 20 , 99 , 12 )

Exercice 4.2: Définition et utilisation de méthodes diverses avec plusieurs sortes


d’arguments.

Soit le segment de code suivant :


def foo ( x , *y , z = 10 )
x + y . size + z
end

puts foo ( 10 , 20 , 30 )
Qu’est-ce qui sera affiché?
Exercice 4.3: Définition d’une méthode avec plusieurs sortes d’arguments.
Ruby 113

4.12 Définitions de classes

Exemple Ruby 4.26 Un script avec une classe (simple) pour des cours.
$ cat cours . rb
# Definition d ’ une classe ( simple !) pour des cours .
class Cours
attr_reader : sigle

def initialize ( sigle , titre , * prealables )


@sigle , @titre , @prealables = sigle , titre , prealables
end

def to_s
sigles_prealables = " "
@prealables . each do | c |
sigles_prealables << " #{ c . sigle } "
end

" < #{ @sigle } ’#{ @titre } ’ (#{ sigles_prealables }) >"


end
end

Exemple Ruby 4.26 Un script avec une classe (simple) pour des cours (suite).
if $0 == __FILE__
# Definition de quelques cours .
inf1120 = Cours . new ( : INF1120 , ’ Programmation I ’ )
inf1130 = Cours . new ( : INF1130 , ’ Maths pour informaticien ’ )
inf2120 = Cours . new ( : INF2120 , ’ Programmation II ’ ,
inf1120 )
inf3105 = Cours . new ( : INF3105 , ’ Str . de don . ’ ,
inf1130 , inf2120 )

puts inf1120
puts inf3105
puts inf1120 . sigle
puts inf1120 . titre
end
Ruby 114

Exemple Ruby 4.27 Appel du script avec une classe pour des cours.
$ ruby cours . rb
< INF1120 ’ Programmation I ’ ( ) >
< INF3105 ’ Str . de don . ’ ( INF1130 INF2120 ) >
INF1120
NoMethodError : undefined method ‘ titre ’ for #< Cours :0 x13969fbe >
( root ) at cours . rb :34

Remarques et explications pour les exemples Ruby 4.26–4.27 :

• Une définition de classe est introduite par le mot-clé class, suivi du nom de
la classe, suivi des attributs et méthodes, suivi de end.

• En Ruby, la convention pour nommer les identificateurs est la suivante :


– On utilise le snake_case pour les variables locales et les paramètres,
ainsi que les noms de méthodes — e.g., initialize, to_s, prealables,
sigles_prealables, etc.
– On utilise le CamelCase, avec une majuscule comme première lettre, pour
les noms de classe — e.g., Cours, Array.
– On utilise uniquement des majuscules avec tirets — Screaming snake case
— pour les constantes, e.g., STDOUT, STDERR, etc.

• Un nom de variable débutant par «@» dénote une variable d’instance — un


attribut de l’objet. Dans l’exemple, un objet de la classe Cours possède donc
trois variables d’instance — trois attributs, toujours privés : @sigle, @titre
et @prealables.

• On crée un nouvel objet à l’aide de la méthode new, méthode que, générale-


ment, on ne définit pas. C’est plutôt la méthode new par défaut (Object) qui
appelle la méthode initialize pour définir l’état initial de l’objet — pour
initialiser les variables d’instance.

• Par défaut, toutes les méthodes sont publiques.

• Il n’est possible d’accèder à un attribut d’un objet que si une méthode appro-
priée, publique, a été définie.
Ruby 115

• Une «déclaration» telle que «attr_reader :sigle» définit un attribut acces-


sible en lecture 7 , en définissant une méthode d’accès appropriée. Une telle
déclaration est donc équivalente à la méthode suivante (définition implicite) :
def sigle
@sigle
end

Note : En fait, «attr_reader :sigle» représente un appel à la méthode


attr_reader avec l’argument :sigle. Voir plus loin (section 4.D).
• On peut indiquer qu’un segment de code ne doit être exécuté que si le script
est appelé directement comme programme, donc ne doit pas être exécuté si le
fichier est utilisé/chargé par un autre fichier. Pour ce faire, il s’agit d’utiliser
la condition «$0 == __FILE__» :
– $0 = Nom du programme principal, i.e., nom du fichier appelé comme
argument direct de ruby.
– __FILE__ = Nom du fichier courant, donc contenant le code où apparait
la variable __FILE__.
– $0 == __FILE__ : Pour notre exemple, cette condition sera vraie seule-
ment pour un appel «$ ruby cours.rb». Si on exécute plutôt un autre
programme/script qui utilise cours.rb, seule la classe Cours sera définie.
• L’appel «puts inf1120.sigle» produit un résultat correct parce que sigle
est bien une méthode publique. Par contre, l’appel «puts 1120.titre» n’est
pas permis puisqu’aucune méthode nommée titre n’a été définie — ni ex-
plicitement (avec def), ni implicitement (avec attr_reader).
• La méthode to_s est utilisée pour obtenir une chaîne de caractères représen-
tant l’objet. Cette méthode est donc équivalente au toString de Java.
• Étant donnée une collection — ici, @prealables — on peut obtenir et traiter
les différents éléments de cette collection à l’aide de l’itérateur each — voir
plus loin.

Pour la classe Cours, définissez une méthode qui permet d’obtenir le titre d’un cours
et une autre méthode qui permet de modifier le titre d’un cours.
Utilisez ensuite cette dernière méthode pour changer le titre du cours inf1120 en
"Programmation Java I".
Exercice 4.4: Méthodes pour lire et modifier le titre d’un cours.

7
Un getter dans la terminologie Java.
Ruby 116

4.13 Lambda-expressions
Les lambda-expressions — λ-expressions — sont le fondement de la programmation
fonctionnelle.
Ruby 117

Exemple Ruby 4.28 Les lambda-expressions : type et méthodes de base.


>> # Une lambda - expression represente un objet ,
# de classe Proc , qu ’ on peut ’ appeler ’.
# Un Proc est donc une " fonction anonyme ".
? > lambda { 0 }. call
=> 0

>> zero = lambda { 0 }


= > #< Proc :0 x5c5eefef@ ( irb ):2 ( lambda ) >

>> zero . class


= > Proc

>> zero . arity # Lambda avec 0 argument !


=> 0
>> zero . parameters
=> []

>> zero . call


=> 0
Ruby 118

? > # Une lambda - expression peut avoir des arguments .


? > inc = lambda { | x | x + 1 }
= > #< Proc :0 x16293aa2@ ( irb ):8 ( lambda ) >

>> inc . arity


=> 1

>> inc . parameters


= > [[: req , : x ]]

>> inc . call ( 3 )


=> 4

# Et le nombre d ’ arguments est verifie !


>> inc . call
ArgumentError : wrong number of arguments (0 for 1)
from [...]
>> inc . call ( 10 , 20 )
ArgumentError : wrong number of arguments (2 for 1)
from [...]

? > double = lambda do | y |


?> y + y
>> end
= > #< Proc :0 x5158b42f@ ( irb ):11 ( lambda ) >

>> double . arity


=> 1

>> double . call ( 3 )


=> 6

Remarques et explications pour l’exemple Ruby 4.28 :

• Une lambda-expression peut être vue comme une «fonction anonyme» — une
Ruby 119

fonction ou méthode sans nom. On peut affecter une telle expression à une
variable, qui réfère alors à cette fonction.

• Une lambda-expression peut aussi être vue comme une expression dont on
retarde l’exécution, expression qui ne sera évaluée qu’au moment où on fera
un appel explicite à call.

• Une lambda-expression est dénotée par le mot clé lambda suivi d’un bloc. Le
style Ruby veut qu’on utilise l’une de deux notations possibles pour les blocs :
– Si le bloc est court (une seule ligne), on utilise «{...}».
– Si le bloc comporte plusieurs lignes, on utilise «do...end», sur des lignes
distinctes.

Règle générale, dans ce qui suit, nous respecterons ce style, sauf parfois pour
rendre plus compacte la mise en page du texte ou des diapositives.

• Étant donné un objet de classe Proc qui dénote une lambda-expression, on


peut déterminer :
– Son arity = le nombre d’arguments que doit recevoir cette lambda-
expression — le nombre d’arguments à fournir lors d’un appel à la méth-
ode call.
– Ses parameters = la liste des paramètres que doit recevoir cette lambda-
expression — donc la liste des arguments à fournir lors d’un appel à
la méthode call, avec le nom du paramètre et son mode (obligatoire,
optionnel, etc.).
Ruby 120

Exemple Ruby 4.29 Les lambda-expressions, comme n’importe quel autre objet,
peuvent être transmises en argument.
>> # Une methode pour executer deux fois du code ( sans arg .).
def deux_fois ( f )
f . call
f . call
end

>> deux_fois ( lambda { print ’ Bonne ’; print ’ journee !\ n ’ } )


Bonne journee !
Bonne journee !
= > nil

>> deux_fois lambda { print ’ Bonne ’; print ’ journee !\ n ’ }


Bonne journee !
Bonne journee !
= > nil

? > # Ici , les () sont obligatoires , sinon erreur de syntaxe ...


?> deux_fois ( lambda do
print ’ Bonne ’
print ’ journee !\ n ’
end )
Bonne journee !
Bonne journee !
= > nil

Remarques et explications pour l’exemple Ruby 4.29 :


• Une lambda-expression, comme n’importe quel objet, peut être transmise en
argument.
• Le méthode deux_fois permet d’exécuter deux fois un bout de code, code
représenté par une lambda-expression.
• Si le bout de code est complexe — composé de plusieurs lignes — il est
préférable d’utiliser do... end plutôt que des accolades. Toutefois, dans notre
exemple, les parenthèses deviennent obligatoires (sinon erreur de syntaxe), ce
qui rend plus difficile la lecture du code.
Ruby 121

• Dans plusieurs cas, on retrouve une structure semblable à celle de cet exemple,
à savoir, une méthode qui exécute un unique bloc de code reçu en dernier argu-
ment. Bien que cela puisse faire avec des lambdas, comme l’illustre l’exemple,
Ruby rend cela encore plus facile avec les blocs, qu’on verra à la prochaine
section.

Exemple Ruby 4.30 Les lambda-expressions, comme n’importe quel objet, peu-
vent être retournées comme résultat d’une fonction.

? > # Une lambda - expression peut etre retournee comme resultat .


? > def plus_x ( x )
lambda { | y | x + y }
end

>> plus_x (3). call (12)


= > 15

? > plus_bis = lambda { | a | lambda { | b | a + b } }


= > #< Proc :0 x2d7275fc@ ( irb ):44 ( lambda ) >

>> plus_bis . call (3). call (12)


= > 15

Remarques et explications pour l’exemple Ruby 4.30 :

• Une lambda-expression étant un objet comme n’importe quel autre, elle peut
être retournée comme résultat d’une méthode, ou même d’une autre lambda-
expression.
Ruby 122

Exemple Ruby 4.31 Le bloc d’une lambda-expression capture les variables non-
locales.
? > # Le bloc d ’ une lambda - expression ’ capture ’
# les variables non - locales utilisees dans le bloc .
? > x = 23
= > 23

>> plus_x = lambda { | y | x + y }


= > #< Proc :0 x72d1ad2e@ ( irb ):31 ( lambda ) >

>> plus_x . call (7)


= > 30

>> x = 999
= > 999

>> plus_x . call 2


= > 1001

Remarques et explications pour l’exemple Ruby 4.31 :

• Une lambda-expression peut utiliser des variables non-locales — ici, x. On


dit alors que la lambda-expression capture ces variables de façon à définir son
environnement d’exécution.
Ruby 123

Pour la classe Cours :

a. Définissez une méthode prealables qui reçoit en argument un predicat —


une lambda-expression — et qui retourne la liste des prélables du cours qui
satisfont ce predicat.

b. Utilisez la méthode prealables pour obtenir les prélables du cours inf3105


dont le sigle contient la chaine "INF".

Remarque : Pour ce dernier point, vous devez utiliser une expression de


pattern-matching. En Ruby, l’expression suivante retourne un résultat non nil
si x, une chaine, matche le motif INF :

/INF/ =~ x

Plus précisément, l’expression retourne nil si le motif n’apparait pas dans la


chaine, sinon elle retourne la position du premier match.

Exercice 4.5: Une méthode pour identifier un sous-ensemble de préalables d’un


cours.
Ruby 124

Exemple Ruby 4.32 Les appels à une lambda-expression peuvent aussi être faits
avec «.()» plutôt qu’avec call — mais c’est rarement utilisé!
>> lambda { 0 }.()
=> 0

>> zero = lambda { 0 }


= > #< Proc :0 x5c5eefef@ ( irb ):2 ( lambda ) >

>> zero .()


=> 0

>> inc = lambda { | x | x + 1 }


= > #< Proc :0 x16293aa2@ ( irb ):8 ( lambda ) >

>> inc .( 3 )
=> 4
Ruby 125

4.14 Blocs
Un bloc est un segment de code entre accolades {. . . } ou entre do. . . end :

a . each { |x| total += x }

a . each_index do |i|
total += a[i]
end

inc = lambda { |x| x + 1 }

double = lambda do |y|


y + y
end

L’utilisation des blocs en Ruby est étroitement liée à l’instruction yield. Voici
tout d’abord quelques définitions du verbe anglais «to yield » :

• to produce (something) as a result of time, effort, or work

• to surrender or relinquish to the physical control of another : hand over pos-


session of

• to surrender or submit (oneself ) to another

Quelques traductions françaises possibles du verbe «to yield » sont «céder» ou


«produire».
L’instruction yield, lorsqu’exécutée dans une méthode, a l’effet suivant :

• elle évalue (exécute) le bloc passé en argument à la méthode


Note : ce bloc peut ne pas apparaitre dans la liste des arguments — argument
implicite
Ruby 126

Exemple Ruby 4.33 Une méthode pour exécuter deux fois un bout de code —
avec un bloc.
>> # Une autre methode pour executer deux_fois du code , avec bloc !
def deux_fois
yield
yield
end

>> deux_fois { print ’ Bonne ’; print ’ journee !\ n ’ }


Bonne journee !
Bonne journee !
= > nil

>> deux_fois do
print ’ Bonne ’
print ’ journee !\ n ’
end
Bonne journee !
Bonne journee !
= > nil

>> deux_fois
LocalJumpError : no block given ( yield )
from ( irb ):1:in ’ deux_fois ’
from ( irb ):3
from / home / tremblay /. rvm / rubies / ruby -2.1.4/ bin / irb :11:in ’ < main > ’
Ruby 127

>> # Methode pour executer k fois du code .


def k_fois ( k )
k . times do
yield
end
end

>> k_fois ( 3 ) do
print ’ Bonne ’
print ’ journee !\ n ’
end
Bonne journee !
Bonne journee !
Bonne journee !

Remarques et explications pour l’exemple Ruby 4.33 :

• Un bloc est indiqué par un bout de code entre {...} (lorsque sur la même
ligne) ou do... end (lorque sur plusieurs lignes).

• Toute méthode, en plus des arguments explicites, peut recevoir un bloc comme
argument implicite. Ce bloc–argument étant implicite, il n’a pas besoin d’être
indiqué dans la liste des arguments — bien qu’il puisse l’être : voir plus loin.

• On peut exécuter le bloc passé en argument implicite en appelant la méthode


yield. Donc : yield ≈ le_bloc_passé_en_argument.call.
Ruby 128

Exemple Ruby 4.34 Une méthode pour évaluer une expression — avec lambda,
avec bloc implicite et avec bloc explicite.
>> # Methode pour evaluer une expression : avec lambda .
def evaluer ( x , y , expr )
expr . call ( x , y )
end

>> evaluer ( 10 , 20 , lambda { | v1 , v2 | v1 + v2 } )


= > 30

>> # Methode pour evaluer une expression : avec bloc implicite .


def evaluer ( x , y )
yield ( x , y )
end

>> evaluer ( 10 , 20 ) { |a , b | a * b }
= > 200

>> # Methode pour evaluer une expression : avec bloc explicite .


def evaluer ( x , y , & expr )
expr . call ( x , y )
end

>> evaluer ( 10 , 20 ) { |a , b | b / a }
=> 2

>> # On peut verifier si un bloc a ete passe ou non .


def evaluer ( x , y )
return 0 unless block_given ?
yield ( x , y )
end

>> evaluer ( 10 , 20 ) { |a , b | b / a }
=> 2
>> evaluer ( 10 , 20 )
=> 0
Ruby 129

>> def foo ( & b )


[ b . class , b . arity , b . parameters ] if block_given ?
end
= > : foo

>> foo
= > nil

>> foo { 2 }
= > [ Proc , 0 , []]

>> foo { | x | x + 1 }
= > [ Proc , 1 , [[: opt , : x ]]]

Remarques et explications pour l’exemple Ruby 4.34 :

• De la même façon qu’un lambda peut recevoir des arguments, un bloc peut
aussi en recevoir.

• Si le dernier paramètre de l’en-tête d’une méthode est préfixé par «&», alors
le bloc transmis à l’appel de la méthode sera associé à cet argument, donc le
bloc devient explicite. Dans ce cas, c’est comme si le bloc avait été transformé
en un lambda et associé au paramètre ; on peut donc l’appeler explicitement
avec call, mais aussi l’exécuter avec yield.

• La méthode block_given? permet de déterminer si un bloc a été transmis


(en dernier argument implicite). Si c’est le cas, on peut alors évaluer le bloc
avec yield.

Même question que la précédente, mais cette fois en utilisant un bloc implicite pour
le prédicat plutôt qu’une lambda-expression.
Exercice 4.6: Une méthode pour identifier un sous-ensemble de préalables d’un
cours.
Ruby 130

4.15 Portée des variables


sigil (Ésotérisme) Symbole graphique ou sceau représentant une inten-
tion ou un être magique.
Source : https://fr.wiktionary.org/wiki/sigil

Ruby utilise un certain nombre de sigils pour indiquer la portée des variables
— pour indiquer dans quelles parties du programme une variable est connue et
accessible :

foo variable locale (à une méthode ou un bloc)


@foo variable d’instance (attribut d’un objet)
@@foo variable de classe (attribut d’une classe)
$foo variable globale (accessible partout)

Matz [the designer of Ruby] stated more than one time that sigils for
globals and for instance variables are there to remind you that you should
not use them directly. You should encode the global information in a class
or a constant, and access the instance variables via accessor methods.
When you’re writing quick & dirty code you can use them, but globals
are evil and the sigils are there to reify a code smell.
Source : http: // c2. com/ cgi/ wiki? TheProblemWithSigils

Exemple Ruby 4.35 Illustration de la vie et portée des variables.


>> # Une definition de methode ne voit pas
# les variables non - locales .
? > x = 22
= > 22

>> def set_x


x = 88
end
= > : set_x

>> set_x
= > 88

>> x # Inchangee !
= > 22
Ruby 131

? > # Un bloc capture les variables non - locales


# si elles existent .
? > def executer_bloc
yield
end
= > : executer_bloc

>> x = 44
= > 44

>> executer_bloc { x = 55 }
= > 55

>> x # Modifiee !
= > 55

? > # Si la variable n ’ existe pas deja ,


# alors est strictement locale au bloc .
?> z
NameError : undefined local variable or method ’z ’ for main : Object
[...]

? > executer_bloc { z = 88 }
= > 88

>> z
NameError : undefined local variable or method ’z ’ for main : Object
[...]
Ruby 132

>> # Une variable globale est accessible partout !


? > $x_glob = 99
= > 99

>> def set_x_glob


$x_glob = " abc "
end
= > : set_x_glob

>> set_x_glob
= > " abc "

>> $x_glob
= > " abc "

>> lambda { $x_glob = [10 , 20] }. call


= > [10 , 20]

>> $x_glob
= > [10 , 20]

>> # Une variable locale est accessible dans l ’ ensemble


# de la methode .
? > def foo ( x )
if x <= 0 then a = 1 else b = " BAR " end
[a , b ]
end
= > : foo

>> foo ( 0 )
= > [1 , nil ]

>> foo ( 99 )
= > [ nil , " BAR " ]
Ruby 133

>> # Mais un bloc definit une nouvelle portee , avec des variables
# strictement locales !
? > def bar ( * args )
args . each do | x |
r = 10
puts x * r
end
r
end
= > : bar

>> bar ( 10 , 20 )
100
200
NameError : undefined local variable or method ’r ’ for main : Object
[...]

Remarques et explications pour l’exemple Ruby 4.35 :


• Les variables non-locales sont pas visibles à l’intérieur d’une définition de mé-
thode — évidemment, les variables d’instance (avec préfixe «@») le sont. Dans
la méthode set_x, le x auquel on affecte 88 est strictement local à la méthode
et donc le x initial est inchangé.
• Par contre, les variables non-locales sont visibles dans un bloc, et donc un bloc
capture les variables non-locales. L’exécution d’un bloc se fait donc dans le
contexte dans lequel le bloc a été créé. Dans notre exemple, le bloc modifie
la variable x et cette variable existait avant l’appel : c’est donc ce x qui est
utilisé, et la variable x non-locale est modifiée.
• Si un bloc introduit une variable locale, i.e., cette variable n’a pas été capturée
par le bloc, alors cette variable est strictement locale au bloc — comme pour
une méthode. Donc, dans le dernier exemple, puisque z n’existait pas avant
l’appel, le z affecté est local au bloc, d’où l’erreur lors de l’utilisation de z
après l’exécution du bloc.
• Les branches d’un if ne définissent pas une nouvelle portée. Une variable
est considérée comme étant existante même si le code la définissant n’est pas
exécuté — avec une valeur par défaut de nil si non initialisé, ce qui n’est pas
la même chose qu’une variable non existante qui soulève une exception si on
tente de l’utiliser.
Ruby 134

4.16 Modules
Modules are a way of grouping together methods, classes, and constants.
Modules give you two major benefits:

1. Modules provide a namespace and prevent name clashes.


2. Modules implement the mixin facility.

Source : http: // ruby-doc. com/ docs/ ProgrammingRuby/ html/ tut_ modules. html

Exemple Ruby 4.36 Les modules comme espaces de noms.


module M1
C1 = 0
end

module M2
C1 = ’ abc ’
end

M1 :: C1 != M2 :: C1 # = > true

Remarques et explications pour l’exemple Ruby 4.36 :

• Un module Ruby, tout comme un package en Java, permet une forme d’encapsu-
lation en permettant de définir des espaces de noms indépendants — des
namespaces distincts. Deux modules peuvent donc définir des constantes ou
des méthodes avec les mêmes noms sans qu’il n’y ait de conflit.

• Pour accéder à une constante définie dans un module, on utilise la notation


«NomModule::NOM_CONSTANTE».
Ruby 135

module Module1
def self . zero
0
end

def un
1
end

def val_x
@x
end

def inc_inc ( y )
inc ( y )
inc ( y )
end
end

class C1
include Module1

def initialize ( x )
@x = x
end

def inc ( y )
@x += y
end
end

class C2
include Module1
end
Ruby 136

Exemple Ruby 4.37 Un module mixin Module1 et son utilisation.


>> # Appel sur le module de la methode de classe .
? > Module1 . zero
=> 0

>> # Appel sur le module de la methode d ’ instance .


? > Module1 . un
NoMethodError : undefined method ’ un ’ for Module1 : Module
...

>> # Appel sur un objet C1 des methodes


?> # de classe et d ’ instance du module .
?> c1 = C1 . new ( 99 )
=> #< C1 :0 x12cf7ab @x =99 >

>> c1 . zero
NoMethodError : undefined method ’ zero ’ for #< C1 :0 x12cf7ab @x =99 >
...
>> c1 . un
=> 1
>> c1 . val_x
= > 99
>> c1 . inc_inc ( 100 )
= > 299

>> # Appel sur un objet C2 des methodes


? > # de classe et d ’ instance du module .
? > c2 = C2 . new
= > #< C2 :0 x1a8622 >
>> c2 . un
=> 1
>> c2 . val_x
= > nil
>> c2 . inc_inc ( 100 )
NoMethodError : undefined method ’ inc ’ for #< C2 :0 x1a8622 >
...

Remarques et explications pour l’exemple Ruby 4.37 :


• Un module — comme une classe — peut définir des méthodes de classe et des
méthodes d’instance.
Ruby 137

• Une méthode de classe d’un module — comme pour une classe — est indiquée
par le préfixe «self.» devant le nom de la méthode. On peut aussi indiquer
le nom du module ou de la classe — par ex., «def Module1.zero» — mais le
style Ruby suggère d’utiliser plutôt «def self.zero».
• Une méthode de classe peut être appelée avec un appel de la forme suivante :
NomDuModule . nom_methode

On peut aussi utiliser la forme suivante d’appel :


NomDuModule :: nom_methode

• Une méthode d’instance d’un module ne peut être appelée que par l’inter-
médiaire d’un objet dont la définition de classe a inclus, avec include,
le module.
La méthode d’instance est alors exécutée comme si elle avait été définie di-
rectement comme méthode d’instance de la classe ayant effectuée l’inclusion.

• Une méthode d’instance définie dans un module peut faire référence à des
attributs ou méthodes qui ne sont pas définies dans le module. Évidem-
ment, pour que ces références soient valides à l’exécution, les attributs ou mé-
thodes en question doivent être définis dans la classe ayant effectué l’inclusion.

• Quelques définitions d’un mixin :

A mixin provides an easy way to dynamically add the methods of


an existing class to a custom class without using inheritance. You
mix in the class, and then add that class’s members to the prototype
object of the custom class.
[. . . ]
A mixin is an atomic unit in an object-oriented language that adds
functionality to another class. Generally, mixins are not meant to
be used on their own, just as you would not order a bowl of nuts at
an ice cream stand. Instead, they provide a small piece of specialized
functionality that you can easily add to a base class.
https: // www. adobe. com/ support/ documentation/ en/ flex/ 1/
mixin/ mixin2. html
Ruby 138

In object-oriented programming languages, a mixin is a class that


contains methods for use by other classes without having
to be the parent class of those other classes. How those other
classes gain access to the mixin’s methods depends on the language.
Mixins are sometimes described as being “included” rather than “
inherited”.
[. . . ]
A mixin can also be viewed as an interface with implemented meth-
ods.
https: // en. wikipedia. org/ wiki/ Mixin
Ruby 139

4.17 Modules Enumerable et Comparable


4.17.1 Module Enumerable
La figure à la page 139 présente la liste des méthodes du module Enumerable —
donc les diverses méthodes disponibles lorsque la méthode each est définie par
une classe et que le module Enumerable est inclus (avec include)!
Des exemples illustrant ces diverses méthodes sont ensuite présentés.
Ruby 140
Ruby 141

Exemple Ruby 4.38 Exemples d’utilisation du module Enumerable.


? > > # La classe Array definit la methode each et
# inclut le module Enumerable .

>> a = [10 , 20 , 30 , 40]


= > [10 , 20 , 30 , 40]

? > # Appartenance d ’ un element .


? > a . include ? 20
= > true

>> a . include ? 999


= > false

>> a
= > [10 , 20 , 30 , 40]

? > # Application fonctionnelle .


? > a . map { | x | x + 2 } # Synonyme = collect .
= > [12 , 22 , 32 , 42]

>> a ??
= > ??

? > # Application imperative ( mutable )!


>> a . map ! { | x | 10 * x }
= > [100 , 200 , 300 , 400]

>> a ??
= > ??

Remarques et explications pour l’exemple Ruby 4.38 :

• Toute classe qui, comme Array, définit une méthode each et inclut le module
Enumerable, hérite automatiquement d’un grand nombre de méthodes :
Ruby 142

? > # Selection / rejet d ’ elements selon un critere .


>> a . select { | x | x >= 300 }
= > [300 , 400]

>> a . reject { | x | x >= 300 }


= > [100 , 200]

>> a
= > [100 , 200 , 300 , 400]

# Il existe aussi des variantes imperatives / mutables :


# select !
# reject !

? > # Obtention du premier element qui satisfait un critere .


>> a
= > [100 , 200 , 300 , 400]

>> a . find { | x | x > 200 } # Synonyme = detect .


= > 300

>> a . find { | x | x < 0 }


= > nil

? > # Quantificateurs .
? > a . all ? { | x | x > 0 }
= > true

>> a . any ? { | x | x > 500 }


= > false
Ruby 143

? > # Reduction avec un operateur binaire .


>> a
= > [100 , 200 , 300 , 400]

? > a . reduce { |x , y | x + y } # Synonyme = inject .


= > 1000

>> a . reduce ( :+ )
= > 1000

>> a . reduce ( &:+ )


= > 1000

>> a . reduce ( :* )
= > 2400000000

>> a . reduce ( 999 , :+ )


= > 1999

? > # Autres exemples de reduction , avec operateurs divers .


>> a . reduce (0) { | max , x | x > max ? x : max }
= > ??

>> a . map { | x | x / 10 }
= > ??

>> a . reduce ([]) { |a , x | a << x / 10 }


= > ??

>> a . reduce ([]) { | ar , x | [ x ] + ar + [ x ] }


= > ??
Ruby 144

? > # Regroupement , dans un Hash , des elements


# avec une meme valeur specifiee par le bloc .
>> a . group_by { | x | x }
= > {100= >[100] , 200= >[200] , 300= >[300] , 400= >[400]}

>> a . group_by { | x | x >= 222 }


= > { false = >[100 , 200] , true = >[300 , 400]}

>> a . group_by { | x | x / 100 }


= > {1= >[100] , 2= >[200] , 3= >[300] , 4= >[400]}

>> a . group_by { | x | x % 2 }
= > {0= >[100 , 200 , 300 , 400]}

>> a . group_by { | x | ( x / 100) % 2 }


= > {1= >[100 , 300] , 0= >[200 , 400]}
Ruby 145

– include? : Permet de déterminer si un élément appartient à la collection.


– map (appelé aussi collect) : Permet d’appliquer une fonction (un bloc)
aux éléments de la collection pour produire une nouvelle collection.
Note : Il existe aussi une variante map! qui modifie la collection.
– select et reject : Permettent de sélectionner ou rejeter les éléments de
la collection qui répondent à un critère spécifié par un bloc pour produire
une nouvelle collection.
Note : Il existe aussi des variantes select! et reject! qui modifient
directement la collection.
– find (appelé aussi detect) qui retourne le premier élément de la collec-
tion qui satisfait un certain critère (spécifié par un bloc).
– all? et any? : Quantificateurs universel (pour tout élément) et existen-
tiel (il existe un élément) — le prédicat évalué pour chaque élément est
défini par le bloc passé en argument.
– reduce (appelé aussi inject) : Combine les éléments de la collection en
utilisant un opérateur binaire ou, dans sa forme la plus générale, un bloc
qui reçoit deux (2) arguments :
∗ Le premier argument est «l’accumulateur», et représente la valeur
obtenue jusqu’à présent. Sa valeur initiale est (possiblement) définie
par la valeur passée en argument à la méthode reduce.
∗ Le deuxième argument est un élément de la collection ;
L’exemple Ruby 4.39 présente une mise en oeuvre possible de reduce,
qui aide à comprendre le rôle des deux arguments du bloc ainsi que de
l’argument de reduce reçu comme valeur initiale.
La réduction à l’aide d’un opérateur binaire simple (commutatif et as-
sociatif) — par exemple «+», «*» — étant un cas souvent rencontré, on
peut aussi passer en argument (explicite) à reduce un symbole dénotant
un tel opérateur, et ce avec ou sans valeur initiale.
– group_by : Produit un Hash où chaque clé est une valeur spécifiée par
le critère associé au bloc et où la définition associée est l’ensemble des
valeurs de la collection qui génèrent cette clé.

Voir p. 139 pour une liste plus détaillée des opérations du module Enumerable.

Donnez une mise en oeuvre, dans un style fonctionnel, de la méthode to_s de la


classe Cours vue précédemment.
Exercice 4.7: Mise en oeuvre fonctionnelle de Cours#to_s.
Ruby 146

Exemple Ruby 4.39 Une mise en oeuvre, en Ruby, de quelques méthodes du mo-
dule Enumerable, méthodes qui utilisent la méthode each de la classe ayant exécuté
l’appel «include Enumerable».
# Mise en oeuvre possible , en Ruby , de quelques methodes
# du module Enumerable : on utilise * uniquement * each !

module Enumerable
def include ?( elem )
each do | x |
return true if x == elem
end

false
end

def find
each do | x |
return x if yield ( x )
end

nil
end

def reduce ( val_initiale )


# Autre argument implicite = bloc recevant deux arguments .
accum = val_initiale
each do | x |
accum = yield ( accum , x )
end

accum
end
end
Ruby 147

4.17.2 Module Comparable


La figure ci-bas présente la liste des méthodes du module Comparable, c’est-à-dire,
les diverses méthodes disponibles lorsque la méthode <=> est définie par une
classe et que le module Comparable est inclus (avec include)!

Les exemples Ruby qui suivent présentent la méthode «<=>» et les méthodes
définies par le module Comparable.

Exemple Ruby 4.40 Tris avec Enumerable et <=>.


>> # Comparaison avec l ’ operateur ’ spaceship ’.
?> 29 <= > 33
=> -1
>> 29 <= > 29
=> 0
>> 29 <= > 10
=> 1
Ruby 148

>> # Tris .
? > a . sort
= > [10 , 29 , 33 , 44]

>> a . sort { |x , y | x <= > y }


= > [10 , 29 , 33 , 44]

>> a . sort { |x , y | -1 * ( x <= > y ) }


= > [44 , 33 , 29 , 10]

>> a . sort { |x , y | ( x % 10) <= > ( y % 10) }


= > [10 , 33 , 44 , 29]

Remarques et explications pour l’exemple Ruby 4.40 :

• L’opérateur de comparaison, dit opérateur «spaceship», doit retourner les


valeurs suivantes pour un appel «x <=> y» :
-1 si x est inférieur à y
0 si x est égal à y
1 si x est supérieur à y
Lorsque cet opérateur est correctement défini (-1, 0 et 1) et que le module
Comparable est inclus, alors les autres opérateurs de comparaison sont alors
automatiquement définis : <, <=, >, >=, ==, !=.

• Une collection qui définit une méthode each, qui inclut le module Enumerable
et dont les éléments peuvent être comparés avec l’opérateur «spaceship» hérite
automatiquement d’une méthode sort, ainsi que des méthodes min et max.
Par défaut, i.e., sans bloc après sort (idem pour max et min), la comparaison
se fait avec l’opérateur «<=>». Par contre, on peut aussi spécifier une méthode
de comparaison en transmettant un bloc, qui doit retourner -1, 0, 1 comme
l’opérateur «<=>».

Remarques et explications pour l’exemple Ruby 4.41 :

• Lorsque la méthode «<=>» est définie par une classe et que cette classe inclut
le module Comparable, diverses méthodes deviennent alors automatiquement
disponibles.
Ruby 149

Dans cet exemple, l’ordre entre deux Cours est déterminé par l’ordre entre
leurs sigles. Donc, étant donné deux cours c1 et c2, c1 <=> c2 == c1.sigle
<=> c2.sigle.

• Puisque la classe Cours inclut le module Comparable, les méthodes de com-


paraison telles que «<», «>=», etc., deviennent automatiquement disponibles.
Ruby 150

Exemple Ruby 4.41 Comparaison et tri de Cours via les sigles.


$ cat cours - bis . rb
require_relative ’ cours ’

class Cours
include Comparable

def <= >( autre )


sigle <= > autre . sigle
end
end

if $0 == __FILE__
# Definition de quelques cours .
inf1120 = Cours . new ( : INF1120 , ’ Programmation I ’ )
inf1130 = Cours . new ( : INF1130 , ’ Maths pour informaticien ’ )
inf2120 = Cours . new ( : INF2120 , ’ Programmation II ’ , inf1120 )
inf3105 = Cours . new ( : INF3105 , ’ Str . de don . ’ , inf1130 , inf2120 )

cours = [ inf3105 , inf1120 , inf2120 , inf1130 ]

# Quelques expressions
puts inf3105 < inf1120
puts inf2120 >= inf1130
cours . sort . each { | c | puts c }
end
------------------------------------

$ ruby cours - bis . rb


false
true
< INF1120 ’ Programmation I ’ ( ) >
< INF1130 ’ Maths pour informaticien ’ ( ) >
< INF2120 ’ Programmation II ’ ( INF1120 ) >
< INF3105 ’ Str . de don . ’ ( INF1130 INF2120 ) >
Ruby 151

Que fait la méthode suivante? Quel nom plus significatif pourrait-on lui donner?
class Array
def mystere ( p )
reduce ( [[] , [] , []] ) do | res , x |
res [1 + ( x <= > p )] << x
res
end
end
end

Exercice 4.8: Méthode mystere sur un Array.


Ruby 152

4.18 Itérateurs définis par le programmeur

Exemple Ruby 4.42 Une classe (simplifiée) pour des Ensembles.


class Ensemble
include Enumerable

# Ensemble initialement vide ( sans element ).


def initialize
@elements = []
end

# Ajout d ’ un element , sauf si deja present !


def < <( x )
@elements << x unless contient ? x
self
end

def each
@elements . each do | x |
yield ( x )
end
end
Ruby 153

def cardinalite
count
end

def contient ?( x )
include ? x
end

def somme ( val_initiale = 0 )


reduce ( val_initiale ) { |s , x | s + x }
end

def produit ( val_initiale = 1 )


reduce ( val_initiale ) { |s , x | s * x }
end

def to_s
" { " << map { | x | x . to_s }. join ( " , " ) << " } "
end
end

Remarques et explications pour l’exemple Ruby 4.42 :

• La classe Ensemble définit une classe pour des objets représentant des en-
sembles d’éléments, i.e., des collections où chaque élément n’est présent au
plus qu’une seule fois.

• Un ensemble nouvellement créé — avec Classe.new, qui appelle initialize


— est vide, i.e., son tableau d’@elements est vide.

• Un opérateur (infixe) tel que «<<» est défini comme n’importe quelle autre
méthode.
Puisqu’on doit modéliser un ensemble, où chaque élément n’est présent au plus
qu’une seule fois, si l’élément est déjà présent dans le tableau des @elements
(include?), alors on ne l’ajoute pas aux @elements.
Dans cet exemple, le résultat retourné par «<<» est self, i.e., l’objet lui-même
— équivalent au this de Java. Ceci permet de chaîner plusieurs appels les
uns à la suite des autres : voir l’exemple Ruby 4.43, qui présente quelques
expressions utilisant cette classe.
Ruby 154

• La méthode cardinalite retourne le nombre d’éléments d’un ensemble —


tous distincts, puisque l’ajout avec «<<» assure leur unicité.

• La classe Ensemble définit une méthode each, qui permet d’itérer sur les
éléments d’un ensemble.

• La classe Ensemble inclut le module Enumerable. Les nombreuses méthodes


de ce module sont donc disponibles : map, select, reject, reduce, all?,
any?, etc.
Les méthodes contient?, somme, produit et to_s peuvent donc être définies
en utilisant les méthodes du module Enumerable, ici, include?, reduce et
map.

• Soulignons que pour que les méthodes somme et produit fonctionnent cor-
rectement, un élément de l’ensemble doit pouvoir répondre aux messages «+»
et «*».

• Les méthodes cardinalite et contient?, puisqu’elles en font qu’appeler une


autre méthode avec un nom différent, aurait pu être définies plus simplement
comme suit — donc les deux définitions de méthodes :
alias : cardinalite : count
alias : contient ? : include ?
Ruby 155

Exemple Ruby 4.43 Quelques expressions utilisant un objet Ensemble.


? > # Cree un ensemble avec divers elements .
? > ens = Ensemble . new << 1 << 5 << 3

= > #< Ensemble :0 x000000023c9298 @elements =[1 , 5 , 3] >


>> ens . to_s
=> "{ 1, 5, 3 }"

? > # L ’ operation << modifie l ’ objet .


? > ens << 2

= > #< Ensemble :0 x000000023c9298 @elements =[1 , 5 , 3 , 2] >


>> ens . to_s
=> "{ 1, 5, 3, 2 }"

?> # Appels a diverses methodes directement definies par Ensemble .


?> ens . contient ? 10
=> false
>> ens . contient ? 2
=> true

>> ens . somme


=> 11
>> ens . somme (33)
=> 44

>> ens . produit


= > 30
Ruby 156

>? > # Appels a des methodes definies par Enumerable .


>> ens . to_s
=> "{ 1, 5, 3, 2 }"

? > ens . map { | x | x * 10 }


= > [10 , 50 , 30 , 20]

>> ens . reject { | x | x . even ? }


= > [1 , 5 , 3]

>> ens . find { | x | x >= 2 }


=> 5

Supposons que dans la classe Array, on veuille définir les méthodes map et select,
et ce utilisant each ou each_index. Quel code faudrait-il écrire?
class Array
def map
...
end

def select
...
end
end

Remarque : Conceptuellement, dans la vraie classe Array, ces méthodes sont


disponibles simplement parce que la classe Array inclut le module Enumerable.
En pratique, la mise en oeuvre de ces méthodes pour la classe Array est faite de
façon spécifique à cette classe, pour des raisons d’efficacité — notamment, méthodes
écrites en C dans Ruby/MRI.
Exercice 4.9: Mises en oeuvre de map et select.
Ruby 157

4.19 Expressions régulières et pattern matching


4.19.1 Les caractères spéciaux
Le tableau 4.2 présente les principaux caractères spéciaux utilisés dans les expres-
sions régulières Ruby.
En gros, les expressions régulières Ruby combinent les éléments des expressions
régulières simples d’Unix — création de groupes et de back references — et les
éléments des expressions régulières étendues — caractères ?, +, etc. — en plus
d’ajouter d’autres éléments. (Seuls quelques-uns de ces éléments additionnels sont
mentionnés dans ce qui suit!)
Ruby 158

\ Supprime la signification spéciale du caractère qui suit


. Un caractère arbitraire
Répétitions
* 0, 1 ou plusieurs occurrences du motif qui précède
? 0 ou 1 occurrence du motif qui précède
+ 1 ou plusieurs occurrences du motif qui précède
{n} Exactement n occurrences du motif qui précède
{n,} Au moins n occurrences du motif qui précède
{,n} Au plus n occurrences du motif qui précède
{n,m} De n à m occurrences du motif qui précède
Ancrages
^ Début de la ligne
$ Fin de la ligne
Classes de caractères
[...] Un caractère qui fait partie de la classe
[^...] Un caractère qui ne fait pas partie de la classe
\d Un nombre décimal
\D Tout sauf un nombre décimal
\s Un espace blanc (espace, tabulation, saut de ligne, etc.)
\S Tout sauf un espace blanc
\w Un caractère alphanumérique = a-zA-Z0-9_
\W Tout sauf un caractère alphanumérique
Autres caractères spéciaux
m1 |m2 Choix entre motif m1 ou motif m2
(...) Création d’un groupe et d’une référence au groupe matché
\b Une frontière de mot
\A Le début de la chaine
\z La toute fin de la chaine
\Z La fin de la chaine (en ignorant possiblement le saut de ligne qui suit)

Tableau 4.2: Les principaux caractères spéciaux utilisés dans les expressions
régulières.
Ruby 159

4.19.2 Les expressions régulières et la méthode «=˜»

Exemple Ruby 4.44 Une expression régulière est un objet de classe Regexp.
>> # Exemples de base .

>> / ab .* zz$ /. class


= > Regexp

>> re = / ab .* zz$ /
=> / ab .* zz$ /
>> re . class
=> Regexp

>> re = Regexp . new ( " ab .* zz$ " )


=> / ab .* zz$ /
>> re . class
=> Regexp

Remarques et explications pour l’exemple Ruby 4.44 :

• Une expression régulière est un objet de classe Regexp.

• Une expression régulière peut être créée à l’aide d’une expression utilisant les
barres obliques — /.../ — ou en créant explicitement avec new un objet de
classe Regexp.
Ruby 160

Exemple Ruby 4.45 Une expression régulière peut être utilisée dans une opération
de pattern-matching avec «=˜».
>> # Exemples de base ( suite ).

>> re = Regexp . new ( " ab .* zz$ " )


= > / ab .* zz$ /

>> re =~ " abcdzz00 "


= > nil

>> re =~ " abcdzz "


=> 0

>> re .=~ " abcdzz "


=> 0

>> re =~ " .... abcdzz "


=> 4

>> " .... abcdzz " =~ re


=> 4

>> puts " Ca matche " if re =~ " .... abcdzz "


Ca matche
= > nil

>> re !~ " abcdzz00 "


= > true

>> re !~ " abcdzz "


= > false

Remarques et explications pour l’exemple Ruby 4.45 :

• L’opération de base pour matcher une chaine et une expression régulière est
l’opérateur «=~».
Ruby 161

Cet opérateur est en fait une méthode de la classe Regexp, mais une version
symétrique est aussi définie dans la classe String. Les appels suivants sont
donc équivalents (les parenthèses sont optionnels!) :

– re =~ ch
– re.=~( ch )
– re.=~ ch
– ch =~ re
• La méthode «=~» retourne la position dans la chaine où débute le match si
un tel match a pu être trouvé. Elle retourne nil si aucun match n’a pu être
trouvé.

• La méthode «!~» retourne true si aucun match n’a pu être trouvé, sinon elle
retourne false.
Ruby 162

4.19.3 Quelques caractères spéciaux additionnels et quelques


options

Exemple Ruby 4.46 Autres caractères spéciaux des motifs et options.


>> # L ’ option i permet d ’ ignorer la casse .

>> / bc / =~ " ABCD "


= > nil

>> / bc / i =~ " ABCD "


=> 1

>> # Un "." * ne matche pas * un saut de ligne ... sauf avec l ’ option m .
# Un \ s matche un saut de ligne .

>> / z . abc / =~ " xyz \ nabc "


= > nil

>> / z \ sabc / =~ " xyz \ nabc "


=> 2

>> / z . abc / m =~ " xyz \ nabc "


=> 2
Ruby 163

Exemple Ruby 4.47 L’option «x» permet de mieux formater des expressions
régulières complexes.
>> motif = /(#{ CODE_REG }) # Le code regional
- # Un tiret
(#{ TEL }) # Le numero de tel .
/x
= > /((? - mix :\ d {3})) # Le code regional
- # Un tiret
((? - mix :\ d {3} -\ d {4})) # Le numero de tel .
/x

>> motif . match " Tel .: 514 -987 -3000 ext . 8213 "
= > #< MatchData " 514 -987 -3000 " 1: " 514 " 2: " 987 -3000 " >

Remarques et explications pour les exemples Ruby 4.46–4.47 :

• L’option «i» permet d’ignorer la casse.

• Par défaut, le pattern matching se fait ligne par ligne, et le «.» ne matche pas
un saut de ligne — alors qu’un \s va le matcher.
Par contre, si on indique l’option «m» — pour multi-lignes — alors le «.»
pourra matcher un saut de ligne.

• L’option «x» permet de mettre en forme des expressions régulières complexes


en utilisant des blancs, sauts de lignes et commentaires, qui sont ensuite ignorés
dans l’opération de matchage.
Ruby 164

Exemple Ruby 4.48 Début/fin de chaine vs. début/fin de ligne.


>> # Debut de ligne vs . debut de chaine .

>> /^ abc / =~ " xxx \ nabc \ n "


=> 4

>> /\ Aabc / =~ " xxx \ nabc \ n "


= > nil

>> # Fin de ligne vs . fin de chaine .

>> / abc$ / =~ " xxx \ nabc \ n "


=> 4

>> / abc \ z / =~ " xxx \ nabc \ n "


= > nil

>> / abc \ n \ z / =~ " xxx \ nabc \ n "


=> 4

>> / abc \ Z / =~ " xxx \ nabc \ n "


=> 4

Remarques et explications pour l’exemple Ruby 4.48 :

• En mode ligne par ligne, le mode par défaut, les ancres «^» et «$» dénotent
le début et la fin de la ligne.

• Si on veut matcher le début ou la fin de la chaine, on utilise «\A» et «\z» ou


«\Z».
La différence entre «\z» et «\Z» : le premier dénote la «vraie» fin de la chaine,
donc ne matchera pas si un saut de ligne suit ; le deuxième, par contre, va
matcher si ce qui suit est uniquement un saut de ligne.
Ruby 165

4.19.4 La classe MatchData

Exemple Ruby 4.49 Les méthodes d’un objet MatchData, objet retourné par
l’opération Regexp#match.
>> # Les objets MatchData .

>> CODE_REG = /\ d {3}/


=> /\ d {3}/
>> TEL = /\ d {3} -\ d {4}/
=> /\ d {3} -\ d {4}/

>> m = /(#{ CODE_REG }) -(#{ TEL })/. match " FOO "
= > nil

>> m = /(#{ CODE_REG }) -(#{ TEL })/.


match " Tel .: 514 -987 -3000 ext . 8213 "
= > #< MatchData " 514 -987 -3000 " 1: " 514 " 2: " 987 -3000 " >

>> m [0.. -1]


= > [ " 514 -987 -3000 " , " 514 " , " 987 -3000 " ]

>> m . begin (0).. m . end (0)


=> 6..18
>> m . begin (1).. m . end (1)
=> 6..9
>> m . begin (2).. m . end (2)
=> 10..18

>> m . pre_match
= > " Tel .: "

>> m . post_match
= > " ext . 8213 "

Remarques et explications pour l’exemple Ruby 4.49 :

• Au lieu d’utiliser la méthode «=~» pour effectuer du pattern-matching, on peut


utiliser à la place la méthode «match».
Ruby 166

• Si la chaine ne matche pas le motif, l’appel à la méthode retourne nil, donc


comme pour «=~».

• Si la chaine matche le motif, alors un objet MatchData est retourné.

• Un objet MatchData possède diverses méthodes, qui permettent de déterminer


ce qui a été matché, les groupes capturés, la partie avant ou après le match,
etc. :

– Les éléments matchés, notamment les groupes, le groupe 0 indiquant la


partie complète ayant été matchée :
∗ m[0] : La partie complète matchée.
∗ m[1] : Le premier groupe capturé par des (...).
∗ m[2] : Le deuxième groupe capturé par des (...).
∗ Etc.
– Les positions de début et fin des groupes matchés (y compris le groupe 0) :
∗ m.begin(i) : La position où débute le match du groupe i.
∗ m.end(i) : La position qui suit la fin du match du groupe i.
– m.pre_match : La partie de la chaine qui précède la partie matchée.
– m.post_match : La partie de la chaine qui suit partie matchée.
Ruby 167

Exemple Ruby 4.50 Les groupes avec noms et les variables spéciales «$i» définies
par la méthode «=˜».
# Des groupes avec noms explicites .

>> m = /(? < code_reg >#{ CODE_REG }) -(? < tel >#{ TEL })/.
match " Tel .: 514 -987 -3000 ext . 8213 "
= > #< MatchData " 514 -987 -3000 " code_reg : " 514 "
tel : " 987 -3000 " >

>> m [: code_reg ]
= > " 514 "

>> m . begin (: code_reg )


=> 6

>> m [: tel ]
= > " 987 -3000 "

>> m . end (: tel )


= > 18

# Les variables speciales $1 , $2 , etc . pour les groupes .

>> if /(#{ CODE_REG }) -(#{ TEL })/ =~


" Tel .: 514 -987 -3000 ext . 8213 "
" code reg . = #{ $1 }; tel . = #{ $2 } "
end
= > " code reg . = 514; tel . = 987 -3000 "

Remarques et explications pour l’exemple Ruby 4.50 :


• Un groupe capturé par des (...) peut être explicitement nommé, et ce en
définissant le groupe capturé avec (?<ident>...).
• Lorsqu’on utilise la méthode «=˜», il est possible d’accéder aux groupes cap-
turés dans le match en utilisant les variables spéciales — $1 (1er groupe),
$2 (2e groupe), etc.
Ruby 168

• Il existe aussi des variables spéciales pour le match dans son ensemble, la partie
avant le match, la partie après, etc., mais les règles de style Ruby veulent qu’on
évite de les utiliser : si on a besoin de ces élément, on utilise plutôt un objet
MatchData explicite créé avec la méthode match et on utilise les méthodes
associées.

Qu’est-ce qui sera imprimé par les instructions p suivantes :


code_permanent = /(\ w {4}) # NOMP
(\ d {2}) # Annee
(\ d {2}) # Mois
(\ d {2}) # Jour
([^\ D ]{2})
/x

m = code_permanent .
match " CP : DEFG11229988 . "

p m [1]
p m [5]
p m . pre_match
p m . post_match

Exercice 4.10: Objet MatchData.


Ruby 169

4.20 Interactions avec l’environnement


Ctte section traite des arguments du programme, des entrées/sorties, des manipu-
lations de fichiers, et de l’exécution de commandes externes.

4.20.1 Arguments du programme

Exemple Ruby 4.51 Les arguments d’un programme Ruby et les variables
d’environnement.
$ cat argv . rb
# !/ usr / bin / env ruby

i = 0
while arg = ARGV . shift do
puts " ARGV [#{ i ++}] = ’#{ arg } ’ (#{ arg . class }) "
end

puts " ENV [ ’ FOO ’] = ’#{ ENV [ ’ FOO ’]} ’ "


ENV [ ’ FOO ’] = ’ FOO argv . rb ’
puts " -----"

Remarques et explications pour l’exemple Ruby 4.51 :

• Les arguments transmis lors de l’appel du programme sont accessibles par


l’intermédiaire du tableau ARGV.

• Contrairement aux scripts Unix, ARGV[0] est le premier argument, et non le


nom du programme — c’est la variable spéciale $0 qui contient le nom du
programme.

• Le tableau ARGV peut être manipulé et modifié. . . sans générer d’avertissement


— puisqu’étant en majuscules, ARGV devrait être traité comme une constante.

• On peut accéder aux variables de l’environnement — au sens Unix — à l’aide


du Hash ENV. La clé à utiliser est la chaine dénotant le nom de la variable.
La valeur d’une variable d’environnement peut aussi être modifiée en utilisant
ENV. Toutefois, cette modification ne sera visible que dans le processus courant
ou dans les enfants de ce processus.
Ruby 170

$ echo $FOO

$ ./ argv . rb
ENV [ ’ FOO ’] = ’’
-----

$ ./ argv . rb 1234 ’ abc " " def ’ abc def " ’"
ARGV [0] = ’1234 ’ ( String )
ARGV [1] = ’ abc " " def ’ ( String )
ARGV [2] = ’abc ’ ( String )
ARGV [3] = ’def ’ ( String )
ARGV [4] = ’’’ ( String )
ENV [ ’ FOO ’] = ’’
-----

$ export FOO = xyz ; ./ argv . rb def ; echo $FOO


ARGV [0] = ’def ’ ( String )
ENV [ ’ FOO ’] = ’xyz ’
-----
xyz

$ FOO =123 ./ argv . rb def ; echo $FOO


ARGV [0] = ’def ’ ( String )
ENV [ ’ FOO ’] = ’123 ’
-----
xyz
Ruby 171

Soit le script suivant :


$ cat argv2 . rb
# !/ usr / bin / env ruby

ENV [ ’ NB ’ ]. to_i . times do


puts ARGV [0] + ARGV [1]
end

Qu’est-ce qui sera imprimé par les appels suivants :


# a.
NB =3 argv2 . rb 3 8

# b.
NB =2 argv2 . rb [1 , 2] [3]

# c.
unset NB ; argv2 . rb [1009 , 229342] [334]

Exercice 4.11: Utilisation de ARGV et ENV.


Ruby 172

4.20.2 Écriture sur le flux de sortie standard : printf, puts,


print et p

Exemple Ruby 4.52 Exemples de conversions implicites lorsqu’on utilise printf.


>> printf " % d \ n " , " 123 "
123
= > nil

>> printf " % s \ n " , " 123 "


123
= > nil

>> printf " % d \ n " , " abc "


ArgumentError : invalid value for Integer (): " abc "
[...]

>> printf " % s \ n " , " abc "


abc
= > nil

>> printf ( " % d \ n " , [10 , 20] )


TypeError : can’t convert Array into Integer
[...]

>> printf ( " % s \ n " , [10 , 20] )


[10 , 20]
= > nil

# On peut aussi utiliser un format pour


# generer une chaine , sans effet
# sur le flux de sortie .

>> res = sprintf " % d \ n " , 123


= > " 123\ n "

>> res
= > " 123\ n "
Ruby 173

Remarques et explications pour l’exemple Ruby 4.52 :

• La méthode printf utilise par défaut le flux standard de sortie, STDOUT.

• Le format utilise les mêmes règles qu’en C.


La différence toutefois est qu’en Ruby, certaines conversions, lorsqu’elles sont
possibles (?!), sont faites de façon implicite.

• La méthode sprintf, comme en C, n’émet aucune information sur le flux de


sortie. Elle génère plutôt une chaine (string printf).
Ruby 174

Exemple Ruby 4.53 Écriture d’un entier ou d’une chaine simple.


$ cat print - et - al . rb
# !/ usr / bin / env ruby

def imprimer ( methode , * valeurs )


puts " *** Avec #{ methode }: "
valeurs . each do | x |
send methode , x
puts " ... "
end
end

imprimer ( : puts , 123 , " 123 " )


imprimer ( : print , 123 , " 123 " )
imprimer ( :p , 123 , " 123 " )

$ ./ print - et - al . rb
*** Avec puts :
123
...
123
...
*** Avec print :
123...
123...
*** Avec p :
123
...
" 123 "
...
Ruby 175

Exemple Ruby 4.54 Écriture d’un tableau d’entiers ou un tableau de chaines.


$ cat print - et - al . rb
# !/ usr / bin / env ruby

def imprimer ( methode , * valeurs )


puts " *** Avec #{ methode }: "
valeurs . each do | x |
send methode , x
puts " ... "
end
end

imprimer ( : puts , [123 , 456] , [ " 123 " , " 456 " ] )
imprimer ( : print , [123 , 456] , [ " 123 " , " 456 " ] )
imprimer ( :p , [123 , 456] , [ " 123 " , " 456 " ] )

$ ./ print - et - al . rb
*** Avec puts :
123
456
...
123
456
...
*** Avec print :
[123 , 456]...
[ " 123 " , " 456 " ]...
*** Avec p :
[123 , 456]
...
[ " 123 " , " 456 " ]
...
Ruby 176

Exemple Ruby 4.55 Écriture d’un objet qui n’a pas de méthodes to_s et
inspect.
$ cat print - et - al . rb
# !/ usr / bin / env ruby

def imprimer ( methode , * valeurs )


puts " *** Avec #{ methode }: "
valeurs . each do | x |
send methode , x
puts " ... "
end
end

class Bar
def initialize ( val ); @val = val ; end
end

imprimer ( : puts , Bar . new (10) )


imprimer ( : print , Bar . new (10) )
imprimer ( :p , Bar . new (10) )

$ ./ print - et - al . rb
*** Avec puts :
#< Bar :0 x000000015022a0 >
...
*** Avec print :
#< Bar :0 x00000001502110 >...
*** Avec p :
#< Bar :0 x00000001501f80 @val =10 >
...
Ruby 177

Exemple Ruby 4.56 Écriture d’un objet qui a des méthodes to_s et inspect.
$ cat print - et - al . rb
# !/ usr / bin / env ruby

def imprimer ( methode , * valeurs )


puts " *** Avec #{ methode }: "
valeurs . each do | x |
send methode , x
puts " ... "
end
end

class Foo
def initialize ( val ); @val = val ; end

def to_s ; " #{ @val } " ; end

def inspect ; "#< Foo : val =#{ @val } > " ; end
end

imprimer ( : puts , Foo . new (10) )


imprimer ( : print , Foo . new (10) )
imprimer ( :p , Foo . new (10) )

$ ./ print - et - al . rb
*** Avec puts :
10
...
*** Avec print :
10...
*** Avec p :
#< Foo : val =10 >
...

Remarques et explications pour les exemples Ruby 4.53–4.56 :

• Outre printf, plusieurs autres méthodes sont disponibles pour émettre sur
le flux de sortie standard (ou tout autre flux, si on utilise un objet approprié
comme récepteur du message), notamment puts, print et p.
Ruby 178

• En gros, voici les principales différences entre ces trois méthodes :

– puts et p ajoutent un saut de ligne à la fin de la chaine émise, alors que


print n’ajoute pas de saut de ligne.
– puts utilise la méthode to_s pour convertir l’objet en une chaine, alors
que p utilise inspect. Quant à print, il utilise aussi to_s, sauf pour les
tableaux.

• Donc, en gros (mais pas tout à fait dans le cas des tableaux), on a les équiva-
lences suivantes :
puts x print x.to_s; print "\n"
p x print x.inspect; print "\n"

• Remarque : p est particulièrement utile pour déboguer, car on voit plus ex-
plicitement le type d’un objet — e.g., dans le cas d’une chaine, les guillemets
sont indiqués explicitement, dans le cas d’un tableau, on a les crochets et les
virgules, etc.

• Lorsqu’un objet ne possède pas de méthode to_s ou inspect, c’est la méthode


de même nom de la classe Object qui est utilisée :
– Object#to_s : Retourne le nom de la classe et l’adresse de l’objet.
– Object#inspect : Retourne le nom de la classe, l’adresse de l’objet et
les valeurs des différentes variables d’instance.
Ruby 179

4.20.3 Manipulation de fichiers

Exemple Ruby 4.57 Différentes façon de lire et d’afficher sur stdout le contenu
d’un fichier texte.
$ cat cat . rb
# !/ usr / bin / env ruby

nom_fichier = ARGV [0]

File . open ( nom_fichier , " r " ) do | fich |


fich . each_line do | ligne |
puts ligne
end
end

$ cat foo . txt


abc def
123 456

xxx
...

$ ./ cat . rb foo . txt


abc def
123 456

xxx
...
Ruby 180

Exemple Ruby 4.57 Différentes façon de lire et d’afficher sur stdout le contenu
d’un fichier texte.
$ cat cat . rb
# !/ usr / bin / env ruby

nom_fichier = ARGV [0]

fich = File . open ( nom_fichier , " r " )

fich . each_line do | ligne |


puts ligne
end

fich . close

$ cat foo . txt


abc def
123 456

xxx
...

$ ./ cat . rb foo . txt


abc def
123 456

xxx
...
Ruby 181

Exemple Ruby 4.57 Différentes façon de lire et d’afficher sur stdout le contenu
d’un fichier texte.
$ cat cat . rb
# !/ usr / bin / env ruby

nom_fichier = ARGV [0]

IO . readlines ( nom_fichier ). each do | ligne |


puts ligne
end

$ cat foo . txt


abc def
123 456

xxx
...

$ ./ cat . rb foo . txt


abc def
123 456

xxx
...
Ruby 182

Exemple Ruby 4.57 Différentes façon de lire et d’afficher sur stdout le contenu
d’un fichier texte.
$ cat cat . rb
# !/ usr / bin / env ruby

nom_fichier = ARGV [0]

puts IO . readlines ( nom_fichier )

$ cat foo . txt


abc def
123 456

xxx
...

$ ./ cat . rb foo . txt


abc def
123 456

xxx
...
Ruby 183

Exemple Ruby 4.58 Différentes façon de lire et d’afficher sur stdout le con-
tenu d’un fichier texte, dont une façon qui permet de recevoir les données par
l’intermédiaire du flux standard d’entrée.
$ cat cat . rb
# !/ usr / bin / env ruby

nom_fichier = ARGV [0]

puts ( nom_fichier ? IO : STDIN ). readlines nom_fichier

$ cat foo . txt


abc def
123 456

xxx
...

$ ./ cat . rb foo . txt


abc def
123 456

xxx
...

$ cat foo . txt | ./ cat . rb


abc def
123 456

xxx
...
Ruby 184

Remarques et explications pour les exemples Ruby 4.57–4.58 :

• Il est possible d’appeler File.open avec un bloc, qui reçoit en argument le


descripteur du fichier ouvert. Dans ce cas, le fichier est automatiquement
fermé lorsque le bloc se termine!

• L’ouverture d’un fichier avec un bloc correspond en fait à la deuxième ap-


proche illustrée : le fichier est ouvert explicitement avec la méthode (de classe)
File.open, puis le fichier est fermé explicitement avec close. Parce qu’il est
dangeureux d’oublier de fermer le fichier avec cette approche, c’est la première
qui est recommandée en Ruby.
De plus, comme on le verra plus loin, le fichier sera fermé même en cas
d’exception.

• Pour lire un fichier de texte, on peut aussi utiliser directement IO.readlines,


qui retourne un tableau des lignes lues.

Un fichier peut évidemment être ouvert dans d’autres modes que la lecture. La
figure 4.5 donne les différents modes pouvant être utilisés.
Ruby 185

Figure 4.5: Modes d’ouverture des fichiers (source : http://ruby-doc.org/


core-2.0.0/IO.html).
Ruby 186

4.20.4 Exécution de commandes

Exemple Ruby 4.59 Exécution de commandes externes avec backticks ou %x{...}


>> # Execution avec backticks .
>> ext = ’ rb ’
= > " rb "

>> puts ‘ls [ e ]*.#{ ext }‘


ensemble . rb
ensemble_spec . rb
entrelacement . rb
= > nil

>> " #{ $ ?} "


= > " pid 29829 exit 0 "

>> # Execution avec % x {...}.


>> puts % x { ls [ e ]*.#{ ext } }
ensemble . rb
ensemble_spec . rb
entrelacement . rb
= > nil

>> $ ?
= > #< Process :: Status : pid 30019 exit 0 >

>> # Emission sur stderr vs . stdout


>> % x { ls www_xx_z }
ls : impossible d’accéder à www_xx_z :
Aucun fichier ou dossier de ce type
=> ""

>> " # $ ? "


= > " pid 29831 exit 2 "
Ruby 187

Figure 4.6: Deux points de vue sur les flux associés à un processus : a) Vue de
l’intérieur du processus — on lit sur STDIN et on écrit sur STDOUT et STDERR;
b) Vue de l’extérieur du processus — on écrit sur STDIN et on lit de STDOUT et
STDERR.
Ruby 188

Exemple Ruby 4.60 Exécution de commandes externes avec Open3.popen3.


$ cat commandes2 . rb
require ’ open3 ’
Open3 . popen3 ( " wc - lw " ) do | stdin , stdout , stderr |
stdin . puts [ " abc def " , " " , " 1 2 3 " ]
stdin . close

puts " -- stdout - - "


puts stdout . readlines
puts " -- stderr - - "
puts stderr . readlines
puts
end

$ ./ commandes2 . rb
-- stdout - -
3 5
-- stderr - -

Exemple Ruby 4.61 Exécution de commandes externes avec Open3.popen3.


$ cat commandes3 . rb
require ’ open3 ’
Open3 . popen3 ( " wc - lw xsfdf . txt " ) do |_ , out , err |
puts " -- out - - "
puts out . readlines
puts " -- err - - "
puts err . readlines
puts
end

$ ./ commandes3 . rb
-- out - -
-- err - -
wc : xsfdf . txt : Aucun fichier ou dossier de ce type
Ruby 189

Remarques et explications pour les exemples Ruby 4.59–4.61 :

• Comme dans un script shell, on peut utiliser les backticks pour lancer l’exécution
d’un programme externe et obtenir son résultat.
Toutefois, bien qu’on puisse utiliser les backticks, règle générale on utilise
plutôt la forme avec %x{...}.

• Ces deux formes permettant l’interpolation de variables et expressions.

• Le statut retourné par la commande est dans la variable «$?», commme dans
un script shell.

• Pour interpoler les variables spéciales, il n’est pas toujours nécessaire de les
mettre entre accolades.

• Ces deux forme ne donnent pas accès explicite aux différents flux. Plus spé-
cifiquement, le résultat retourné est ce qui est produit sur le flux de sortie
standard (stdout).

• On peut utiliser le module Open3 pour un contrôle plus fin des flux, notamment
pour avoir accès au flux d’erreur (stderr).

• L’exemple utilise la méthode open3, qui permet de manipuler explicitement


tant le flux d’entrée que les flux de sortie.
On remarque une chose intéressante dans ce contexte, où le bloc fournit les
données et obtient les résultats : le flux stdin est utilisé en écriture, alors que
les flux stdout et stderr sont utilisés en lecture!

• Signalons que les noms des flux utilisés à l’intérieur du bloc sont arbitraires,
comme l’illustre le dernier exemple.
Ruby 190

4.21 Traitement des exceptions


4.21.1 Classe Exception et sous-classes standards
En Ruby (comme en Java d’ailleurs), les exceptions sont aussi des objets. La fi-
gure 4.7 présente la hiérarchie de classe des exceptions standards prédéfinies.

NoMemoryError
ScriptError
LoadError
NotImplementedError
SyntaxError
SignalException
Interrupt
StandardError -- default for rescue
ArgumentError
IndexError
StopIteration
IOError
EOFError
LocalJumpError
NameError
NoMethodError
RangeError
FloatDomainError
RegexpError
RuntimeError -- default for raise
SecurityError
SystemCallError
Errno::*
SystemStackError
ThreadError
TypeError
ZeroDivisionError
SystemExit
fatal -- impossible to rescue

Figure 4.7: Hiérarchie des classes/sous-classes standards pour les exceptions


(source : http://ruby-doc.org/core-2.1.1/Exception.html).
Ruby 191

4.21.2 Attraper et traiter une exception

Exemple Ruby 4.62 Une méthode div qui attrape et traite diverses exceptions.
>> def div ( x , y )
begin
z = x / y
rescue ZeroDivisionError = > e
puts " *** Division par 0 (#{ e }) "
p e . backtrace
nil
rescue Exception = > e
puts " *** Erreur = ’#{ e . inspect } ’ "
end
end
= > : div

>> div 3 , 0
*** Division par 0 ( divided by 0)
[ " ( irb ):4: in ’/ ’ " ,
" ( irb ):4: in ’ div ’" , " ( irb ):14: in ’ irb_binding ’" ,
" / home / tremblay /. rvm / rubies / ruby -2.1.4/ lib / ruby /2.1.0/ irb / workspace . rb :86: in ’ eval
...,
" / home / tremblay /. rvm / rubies / ruby -2.1.4/ bin / irb :11: in ’< main > ’ " ]
= > nil

>> div 3 , nil


*** Erreur = ’#<TypeError: nil can’t be coerced into Fixnum>’
= > nil

>> div nil , 3


*** Erreur = ’#<NoMethodError : undefined method ’/’ for nil : NilClass>’
= > nil
Ruby 192

Exemple Ruby 4.63 Une méthode traiter_fichier qui attrape et traite des
exceptions et qui s’assure de restaurer le système dans un bon état, qu’une exception
soit signalée ou non — dans ce cas-ci, en s’assurant de fermer le descripteur du fichier
ayant été ouvert.
>> def traiter_fichier ( fich )
f = File . open ( fich )
begin
traite r_contenu_ fichier ( f . readlines )
puts " +++ Traitement termine "
rescue Exception = > e
puts " *** Erreur = ’#{ e . inspect } ’ "
ensure
f . close
end

f . inspect # Pour voir l ’ etat final de f .


end
= > : traiter_fichier

>> traiter_fichier ( " foo . txt " )


+++ Traitement termine
= > " # < File : foo . txt ( closed ) > "

>> traiter_fichier ( " bar . txt " )


*** Erreur = ’# < RuntimeError : Erreur dans traiter_contenu_fichier > ’
= > " # < File : bar . txt ( closed ) > "
Ruby 193

Exemple Ruby 4.64 La méthode File.open, lorsqu’appelée avec un bloc, assure


que le fichier sera fermé, qu’une exception survienne ou pas.
>> def traiter_fichier ( fich )
le_f = nil
File . open ( fich ) do | f |
le_f = f
begin
traite r_contenu_f ichier ( f . readlines )
puts " +++ Traitement termine "
rescue Exception = > e
puts " *** Erreur = ’#{ e . inspect } ’ "
end
end
le_f . inspect
end
= > : traiter_fichier

>> traiter_fichier ( " bar . txt " )


*** Erreur = ’# < RuntimeError : Erreur dans traiter_contenu_fichier > ’
= > " # < File : bar . txt ( closed ) > "

Remarques et explications pour les exemples Ruby 4.62–4.64 :


• Une séquence d’instructions avec traitement d’exceptions est introduite par
begin/end.
• Une clause rescue permet d’indiquer quelle/quelles exceptions est/sont traitée/s
par cette clause. L’identificateur qui suit «=>» donne un nom à l’exception
attrapée et donc en cours de traitement.
• Il est possible d’attraper n’importe quelle exception en indiquant «rescue
Exception». Par contre, si on indique simplement «rescue», ceci est équiva-
lent à «rescue StandardError».
• Un bloc de traitement d’exception peut (devrait) aussi inclure une clause
ensure. Les instructions associées à cette clause seront toujours exécutées
— qu’une exception survienne ou pas.
• La méthode File.open utilisée avec un bloc assure de toujours fermer
le fichier ayant été ouvert, qu’une exception survienne ou pas. En
général, il est donc préférable d’utiliser cette forme de File.open.
Ruby 194

4.21.3 Signaler une exception

Exemple Ruby 4.65 Exemples illustrant l’instruction fail, appelée avec 0, 1 ou


2 arguments.
>> class MonException < RuntimeError
def initialize ( msg = nil )
super
end
end
= > : initialize

>> def executer


begin
yield
rescue Exception = > e
" classe = #{ e . class }; message = ’#{ e . message } ’ "
end
end
= > : executer

>> executer { fail }


= > " classe = RuntimeError ; message = ’’"

>> executer { fail " Une erreur ! " }


= > " classe = RuntimeError ; message = ’ Une erreur ! ’ "

>> executer { fail MonException }


= > " classe = MonException ; message = ’ MonException ’"

>> executer { fail MonException , " Probleme !! " }


= > " classe = MonException ; message = ’ Probleme !! ’ "
Ruby 195

Exemple Ruby 4.66 Exemples illustrant l’instruction raise utilisée pour resig-
naler une exception.
>> def executer
begin
yield
rescue Exception = > e
puts " classe = #{ e . class }; message = ’#{ e . message } ’ "
raise
end
end
= > : executer

>> executer { fail MonException , " Probleme !! " }


classe = MonException ; message = ’ Probleme !! ’
MonException : Probleme !!
from ( irb ):16: in ’ block in irb_binding ’
from ( irb ):9: in ’ executer ’
from ( irb ):16
from / home / tremblay /. rvm / rubies / ruby -2.1.4/ bin / irb :11: in
’ < main > ’

Remarques et explications pour les exemples Ruby 4.65–4.66 :

• L’instruction fail permet de signaler une exception. Cette instruction peut


recevoir divers arguments :

– Aucun argument : soulève une exception RuntimeError, sans message


associé.
– Un unique argument String : soulève une exception RuntimeError, avec
la chaine utilisée comme message.
– Un unique argument qui est une sous-classe d’Exception : soulève une
exception de la classe indiquée, avec le nom de la classe utilisée comme
message.
– Un premier argument qui est une sous-classe d’Exception et une deux-
ième argument qui est un String : soulève une exception de la classe
indiquée, avec la chaine utilisée comme message.

• Il est aussi possible d’utiliser l’instruction raise pour signaler une exception.
Les deux sont en fait des synonymes.
Ruby 196

Certains auteurs suggèrent d’utiliser fail et raise comme suit :

– On utilise fail lorsqu’on veut signaler une nouvelle exception, donc suite
à un problème qu’on vient tout juste de détecter — premier appel/signal.
– On utilise raise lorsqu’on désire resignaler une exception, qui a déjà
signalée. Par exemple, on exécute une clause rescue, on fait certains
traitements, puis on resignale la même exception pour que les méthodes
appelantes puissent elles aussi traiter l’exception. Dans ce cas, il n’est
pas nécessaire d’indiquer explicitement le nom de l’exception.
Ruby 197

4.22 Autres éléments de Ruby


4.22.1 L’opérateur préfixe «*»

Exemple Ruby 4.67 Utilisation de l’opérateur «*» (splat) devant un objet —


Range, scalaire ou Range — dans une expression.
>> # L ’ operateur " splat " (*) devant un tableau " enleve " un niveau de
# tableau , i . e . , integre directement les elements du tableau plutot
# que le tableau lui - meme .

>> a = [98 , 99]


= > [98 , 99]

>> [1 , [10 , 20] , a , 1000]


= > [1 , [10 , 20] , [98 , 99] , 1000]

>> [1 , *[10 , 20] , *a , 1000]


= > [1 , 10 , 20 , 98 , 99 , 1000]
Ruby 198

>> # L ’ operateur splat (*) devant un scalaire ou un Range genere un


# tableau avec l ’ element ou les elements indiques ... mais pas
# n ’ importe ou .

>> a = *10
= > [10]

>> a = *(1..10)
= > [1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10]

>> (1..10). to_a


= > [1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10]

# Mais ...

>> *(1..10)
SyntaxError : ( irb ):41: syntax error , unexpected ’\ n ’ ,
expecting :: or ’[ ’ or ’. ’
...

>> *10
SyntaxError : ( irb ):33: syntax error , unexpected ’\ n ’ ,
expecting :: or ’[ ’ or ’. ’
...
Ruby 199

Exemple Ruby 4.68 Utilisation de l’opérateur «*» du coté gauche d’une affecta-
tion parallèle (multiple).
>> # Dans la partie gauche d ’ une affectation parallele , un * permet de
# << deconstruire > > un tableau . Dans ce cas , la variable
# prefixee avec * doit etre unique et va denoter un sous - tableau
# d ’ elements .

>> a , b , c = [10 , 20 , 30 , 40]


= > [10 , 20 , 30 , 40]

>> puts " a = #{ a }; b = #{ b }; c = #{ c } "


a = 10; b = 20; c = 30
= > nil

>> a , *b , c = [10 , 20 , 30 , 40]


= > [10 , 20 , 30 , 40]

>> puts " a = #{ a }; b = #{ b }; c = #{ c } "


a = 10; b = [20 , 30]; c = 40
= > nil

>> premier , * derniers = [10 , 20 , 30]


= > [10 , 20 , 30]

>> puts " premier = #{ premier }; derniers = #{ derniers } "


premier = 10; derniers = [20 , 30]
= > nil

>> * premiers , dernier = [10 , 20 , 30]


= > [10 , 20 , 30]

>> puts " premiers = #{ premiers }; dernier = #{ dernier } "


premiers = [10 , 20]; dernier = 30
= > nil
Ruby 200

Exemple Ruby 4.69 Utilisation de «*» dans la spécification de paramètres de


méthodes : l’effet est semblable à des affectations parallèles.
>> # L ’ utilisation de * s ’ applique aussi aux parametres
# formels d ’ une methode , ainsi qu ’ aux arguments effectifs
# ( expressions passees en argument ).
>> def foo ( x , * args )
puts " x = #{ x } "
args . each_index { | k | puts " args [#{ k }] = #{ args [ k ]} " }
end
= > : foo

>> foo ( 10 )
x = 10
= > []

>> foo ( 10 , 20 )
x = 10
args [0] = 20
= > [20]

>> foo ( 10 , 20 , 30 )
x = 10
args [0] = 20
args [1] = 30
= > [20 , 30]

>> foo ( [10 , 20 , 30] )


x = [10 , 20 , 30]
= > []

>> foo ( *[10 , 20 , 30] )


x = 10
args [0] = 20
args [1] = 30
= > [20 , 30]
Ruby 201

4.22.2 L’opérateur préfixe «&» pour la manipulation de blocs

Exemple Ruby 4.70 Utilisation de l’opérateur «&» pour rendre explicite un bloc
comme paramètre d’une méthode.
>> # L ’ operateur prefixe & utilise devant le dernier parametre
# rend explicite le bloc transmis a l ’ appel de la methode .
# Ce parametre est alors un objet Proc pouvant
# etre execute avec call .

>> def call_yield ( x , & bloc )


return x unless block_given ?

[ bloc . class , bloc . call ( x ) , yield ( x ) ]


end
= > : call_yield

>> call_yield ( 99 )
= > 99

>> call_yield ( 99 ) { | x | x + 10 }
= > [ Proc , 109 , 109]
Ruby 202

Exemple Ruby 4.71 Utilisation de l’opérateur «&» pour transformer un objet


lambda ou Symbole en bloc.
>> # L ’ operateur prefixe & devant une lambda expression
# transforme l ’ objet Proc en un bloc .
# Ce bloc peut alors transmis explicitement comme
# dernier argument ( argument additionnel en plus
# des arguments non blocs explicites ).

>> double = lambda { | x | 2 * x }


= > #< Proc :0 x000000028b0950@ ( irb ):24 ( lambda ) >

>> call_yield ( 2 ) { | x | 2 * x }
= > [ Proc , 4 , 4]

>> call_yield ( 2 ) double


SyntaxError : ( irb ):26: syntax error , unexpected tIDENTIFIER , expecting end-of -
...

>> call_yield ( 2 ) & double


TypeError : Proc can’t be coerced into Fixnum
...

>> call_yield ( 2 , & double )


= > [ Proc , 4 , 4]
Ruby 203

>> # Cette transformation s ’ applique meme lorsque le bloc


# est implicite .
# Et elle s ’ applique aussi aux symboles ,
# via un appel implicite a to_proc .

>> def yield_un_arg ( x )


yield ( x )
end
= > : yield_un_arg

>> yield_un_arg ( 24 , & double )


= > 48

>> yield_un_arg ( 24 , &: even ? )


= > true

>> # : s . to_proc == Proc . new { | o | o . s } (... ou presque )


>> yield_un_arg ( 24 , &: even ?. to_proc )
= > true

>> yield_un_arg ( 24 , &: - )


ArgumentError : wrong number of arguments (0 for 1)
...

>> yield_un_arg ( 24 , &: - @ ) # Voir section suivante .


= > -24
Ruby 204

4.22.3 Les opérateurs (préfixes) unaires

Exemple Ruby 4.72 Opérateurs (préfixes) unaires définis par le programmeur.


>> class Foo
def +( autre )
puts " self = #{ self }; autre = #{ autre } "
end

def + @
puts " self = #{ self } "
end
end
= > :+ @

>> foo = Foo . new


= > #< Foo :0 x000000019910c8 >

>> foo + 10
self = #< Foo :0 x000000019910c8 >; autre = 10
= > nil

>> + foo
self = #< Foo :0 x000000019910c8 >
= > nil

Remarques et explications pour l’exemple Ruby 4.72 :

• Les symboles tels que «:+» et «:-» dénotent par défaut les opérateurs bi-
naires.

• Pour référer aux opérateurs (préfixes) unaires, on utilise les symboles «:+@»
ou «:+@».

• Comme n’importe quelles autres méthodes, les méthodes pour les opétateurs,
tant binaires qu’unaires, peuvent être définies par le programmeur.
Ruby 205
Ruby 206

4.A Installation de Ruby sur votre machine


Voici comment procéder pour installer Ruby ou JRuby sur une machine Linux avec
CentOS — donc un Linux comme sur les machines des laboratoires labunix.
Les étapes devraient être les mêmes pour d’autres versions de Linux (pas testé /)
ou pour Mac OS (testé ,). Quant à Windows,. . . désolé, mais aucune idée /
L’installation décrite utilise rvm (Ruby Version Manager ), un outil qui permet
d’installer et utiliser plusieurs versions différentes de Ruby (JRuby, Ruby/MRI 1.9,
2.1, 2.2, etc.). Comme le suggère le site Web de rvm8 , à cause de la façon dont sont
gérées les bibliothèques Ruby (les gems), il est préférable que rvm soit installé dans
votre compte personnel — donc sans installation sudo.
Voici donc les étapes à suivre :
1. Obtenir la clé pour rvm et obtenir rvm (dernière version stable) :
$ gpg -- keyserver \
hkp :// keys . gnupg . net \
-- recv - keys \
409 B 6 B 1 7 9 6 C 2 7 5 4 6 2 A 1 7 0 3 1 1 3 8 0 4 B B 8 2 D 3 9 D C 0 E 3
$ \ curl - sSL https :// get . rvm . io | bash -s stable

2. Activer les fonctions associées à rvm :


$ source ~/. rvm / scripts / rvm

Pour la programmation parallèle avec la bibliothèque PRuby (cours INF5171/INF7235),


il faut installer jruby — rvm list permet de vérifier qu’il est bien installé :
$ rvm install jruby
$ rvm list

3. Installer le gem bundler, requis ultérieuement pour la gestion des gems :


$ gem install bundler

8
https://rvm.io/
Ruby 207
Ruby 208

4.B Le cadre de tests unitaires MiniTest


Nous expliquons tout d’abord ce qu’est un cadre de tests. Nous présentons ensuite
le cadre de tests «standard» pour Ruby : MiniTest.

4.B.1 Tests unitaires et cadres de tests


Niveaux de tests
Il existe différents niveaux de tests [RK03] :

• Tests unitaires : Un test unitaire vérifie le bon fonctionnement d’un module,


d’une classe ou d’un composant indépendant. Les tests unitaires forment la
fondation sur laquelle repose l’ensemble des activités de tests : il est inutile de
tester l’ensemble du système si chacun des modules n’a pas été testé à fond.

• Tests d’intégration : Les tests d’intégration vérifient que les principaux sous-
systèmes fonctionnent correctement, c’est-à-dire que les différents modules qui
composent un sous-système donné sont correctement intégrés ensemble. Ces
tests peuvent être vus comme une forme de tests unitaires, l’unité étant alors
un groupe (cohésif ) de modules plutôt qu’un unique module.

• Tests de système : Les tests de système vérifient le fonctionnement du système


dans son ensemble, en termes des fonctionnalités attendues du système.

• Tests d’acceptation : Les tests d’acceptation sont des tests, de niveau système,
effectués lorsque le système est prêt à être déployé, donc juste avant qu’il soit
livré et officiellement installé.

Dans ce qui suit, où l’on s’intéresse à la mise en oeuvre de petites unités de


programmes, on s’intéresse plus particulièrement aux tests unitaires.

Pratique professionnelle et tests


De nos jours, on considère que dans une pratique professionnelle de développement
de logiciels, l’écriture de tests unitaires fait partie intégrante du processus d’écriture
de code — en d’autres mots, «code source = programme + tests».
En fait, certains auteurs suggèrent même d’utiliser une approche dite de «développe-
ment piloté par les tests» («Test-Driven Development» [Bec03]). Une telle approche,
proposée initialement par les promoteurs de la Programmation eXtrême (XP = eX-
treme Programming [Bec00, AMN02]), repose principalement sur la pratique d’écrire
les tests avant le programme (Test first) :
Ruby 209

• Les cas de tests devraient être développés et écrits (codés) avant le code lui-
même!
• Du nouveau code ne devrait jamais être écrit s’il n’y a pas déjà un cas de test
qui montre que ce code est nécessaire.

Never write a line of functional code without a broken test case.


(K. Beck [Bec01])

Cadres de tests
Qu’on utilise ou non une telle approche de développement piloté par les tests, il
est malgré tout fondamental, lorsqu’on écrit un programme, de développer des tests
appropriés qui vérifient son bon fonctionnement. De plus, il est aussi important
que ces tests puissent être exécutés fréquemment et de façon automatique — ne
serait-ce que pour assurer que tout fonctionne bien lorsque des modifications sont
effectuées (tests de non régression du code).
De nos jours, il existe de nombreux outils qui permettent d’automatiser l’exécution
des tests unitaires, qui facilitent le développement de tels tests et leur association
au code testé, et ce peu importe le langage de programmation utilisé. Ces outils
sont appelés des cadres de tests (tests frameworks).
Le cadre de tests le plus connu est JUnit [BG98, Bec01, HT03], popularisé par
les promoteurs de l’approche XP (eXtreme Programming). Des cadres équivalents
existent pour divers autres langages.9 Dans ce qui suit, nous allons présenter un
cadre de tests développé pour Ruby et maintenant intégré au noyau du langage :
MiniTest.
Mais auparavant, il est utile de comprendre comment fonctionnent de tels cadres
de tests. Tout d’abord, la caractéristique la plus importante de tous les cadres de
tests est qu’on utilise des assertions pour décrire et spécifier ce qui doit être vérifié.
En JUnit, de telles assertions ont la forme suivante et ont toujours la propriété que
rien n’est signalé si l’assertion est vérifiée :
assertEquals ( expectedResult , value )
assertEquals ( expectedResult , value , precision )
assertTrue ( booleanExpression )
assertNotNull ( reference )
etc .
En d’autres mots, ceci implique qu’aucun résultat n’est produit ou généré si le
test ne détecte pas d’erreur.
9
Par exemple, voir http://www.xprogramming.com/software.htm.
Ruby 210

D’autres caractéristiques des cadres de tests sont les suivantes :

• Ils permettent l’exécution automatique des tests — support pour les tests de
(non-)régression, pour l’intégration continue, etc..

• Ils permettent l’organisation structurée des jeux d’essais (cas de tests, suites
de tests, classes de tests).
Plus spécifiquement :
– Un cas de tests porte sur une fonctionnalité limitée, une instance partic-
ulière d’une méthode ou procédure/fonction.
– Une suite de tests regroupe un certain nombre de cas de tests, qui représen-
tent diverses instances liées à une même procédure ou groupe de procé-
dures liées entre elles.
– Une classe de tests, dans un langage objet, regroupe l’ensemble des suites
de tests permettant de tester l’ensemble des fonctionnalités du module,
c’est-à-dire de la classe. Un programme de tests joue le même rôle dans
un contexte impératif et procédural (non orienté objets).

• Ils fournissent des mécanismes pour la construction d’échafaudages de tests


— par exemple, setUp, tearDown en JUnit —, lesquels permettent de définir
le contexte d’exécution d’une suite de tests.

• Ils fournissent des mécanismes permettant d’analyser les résultats de l’exécution


des tests, ainsi que signaler clairement les cas problématiques.

4.B.2 Le cadre de tests MiniTest


Dans ce qui suit, nous présentons, toujours à l’aide d’exemples, les principaux élé-
ments de MiniTest, le cadre de tests qui fait partie intégrante du noyau de Ruby
(depuis la version 1.9). Pour plus de détails, voir le site Web suivant :
http://ruby-doc.org/stdlib-2.0.0/libdoc/minitest/rdoc/MiniTest.html
Tout d’abord, il faut souligner que MiniTest permet deux formes de tests :

• Des tests unitaires «classiques», avec des assertions, qui conduisent à des tests
semblables à ce qu’on obtient en Java avec JUnit.
Ruby 211

Dans cette forme, un programme de tests pour une méthode bar d’une classe Foo
aurait l’allure suivante :
class TestFoo < MiniTest :: Unit :: TestCase
def setup
@foo = Foo . new
end

def t e s t_ b ar _ e st _ in i t ia l em e n t_ 0
assert_equal 0 , @foo . bar
end
...
end

• Des tests unitaires dits de «spécifications», avec des «exceptations», qui con-
duisent à des tests semblables à ce qu’on obtient avec RSpec [CAD+ 10], un gem
Ruby qui définit un langage de tests — un langage-spécifique au domaine10
pour les tests.
Dans cette forme, un programme de tests pour une méthode bar d’une classe
Foo aurait l’allure suivante :
describe Foo do
describe " # bar " do
before do
@foo = Foo . new
end

it " retourne une taille nulle lorsque cree " do


@foo . bar . must_equal 0
end
...
end
...
end

C’est cette dernière forme que nous allons présenter dans les exemples qui suivent.
10
DSL = Domain Specific Language [Fow11].
Ruby 212

4.B.3 Des spécifications MiniTest pour la classe Ensemble

Exemple Ruby 4.73 Une suite de tests pour la classe Ensemble (partie 1)
require ’ minitest / autorun ’
require ’ minitest / spec ’

require_relative ’ ensemble ’

describe Ensemble do
before do
@ens = Ensemble . new
end

describe ’# contient ? ’ do
it " retourne faux quand un element n ’ est pas present " do
refute @ens . contient ? 10
end

it " retourne vrai apres qu ’ un element ait ete ajoute " do


refute @ens . contient ? 10
@ens << 10
assert @ens . contient ? 10
end
end

# ...
Ruby 213

Exemple Ruby 4.74 Une suite de tests pour la classe Ensemble (partie 2)
# ...

describe ’# < < ’ do


it " ajoute un element lorsque pas deja present " do
@ens << 10
assert @ens . contient ? 10
end

it " laisse l ’ element ajoute lorsque deja present " do


@ens << 10
assert @ens . contient ? 10

@ens << 10
assert @ens . contient ? 10
end

it " retourne self ce qui permet de chainer des operations " do


res = @ens << 10
res . must_be_same_as @ens
end
end

# ...
Ruby 214

Exemple Ruby 4.75 Une suite de tests pour la classe Ensemble (partie 3)
# ...

describe ’# cardinalite ’ do
it " retourne 0 lorsque vide " do
@ens . cardinalite . must_equal 0
end

it " retourne 1 lorsqu ’ un seul et meme element est ajoute , 1 ou plusieurs


@ens << 1
@ens . cardinalite . must_equal 1

@ens << 1 << 1 << 1


@ens . cardinalite . must_equal 1
end

it " retourne le nombre d ’ elements distincts peu importe le nombre de fois


@ens << 1 << 1 << 1 << 2 << 2 << 1 << 2
@ens . cardinalite . must_equal 2
end
end
end

Les exemples Ruby 4.73–4.75 présentent une suite de tests pour la classe Ensemble
— voir l’exemple Ruby 4.42 (p.-152) pour la mise en oeuvre de cette classe.

Remarques et explications pour les exemples Ruby 4.73–4.75 :

• Le describe de niveau supérieur indique généralement le nom de la classe


testée — c’est une règle de style, pas une règle syntaxique.
Les describes internes indiquent quant à eux les noms des méthodes. Un nom
de méthode tel que «#foo» indique une série de cas de tests pour la méthode
d’instance foo, alors qu’un nom tel que «.foo» indique une série de cas de
tests pour la méthode de classe foo.

• Le bloc de code indiqué par before sera exécuté avant chaque cas de test. Il
sert à définir le contexte d’exécution de chacun des tests. Ici, pour éviter la du-
plication de code, on alloue un nouvel objet Ensemble, initialement vide, qu’on
affecte à la variable @ens. Puisque cette variable est une variable d’instance
du test, elle sera accessible dans chacun des cas de test.
Ruby 215

• Chaque appel à it décrit un cas de test spécifique, qui ne devrait tester qu’une
et une seule chose. Règle générale (règle de style, pas syntaxe), il ne devrait
y avoir qu’une seule assertion (expectation) par tests.

• Une assertion telle que «assert expr » affirme que l’expression expr est vraie.
Si c’est le cas, le test réussit — donc, en mode non verbeux, un «.» sera
affiché. Par contre, si expr est fausse, alors le test échoue et un message
d’erreur approprié sera affiché — voir plus bas.
Une assertion telle que «refute expr » affirme que l’expression expr est fausse
— dont «refute expr » est équivalent à «assert !expr ».
• La clause «res.must_be_same_as @ens» affirme que res et @ens dénotent en
fait le même objet. Cette clause est donc équivalente à la suivante :
assert res . equal ? @ens

• La clause «@ens.cardinalite.must_equal 0» est équivalente à l’une ou l’autre


des clauses suivantes :
assert @ens . cardinalite == 0
assert_equal 0 , @ens . cardinalite

La deuxième clause serait celle utilisée dans la forme classique de descriptions


des cas de tests (à la JUnit), qui distingue clairement entre le résultat attendu
(premier argument) et le résultat obtenu (deuxième argument). L’avantage
d’utiliser must_equal ou assert_equal plutôt qu’un simple assert est que
le message d’erreur résultant est plus clair et explicite :

============================
Avec simple assert
============================
1) Failure:
Ensemble::#cardinalite#test_0003_retourne [...] [ensemble_spec.rb:61]:
Failed assertion, no message given.

============================
Avec must_equal/assert_equal
============================
1) Failure:
Ensemble::#cardinalite#test_0003_retourne [...] [ensemble_spec.rb:61]:
Expected: 2
Actual: 0
Ruby 216

Exemple Ruby 4.76 Des exemples d’exécution de la suite de tests pour la classe
Ensemble.
======================
Execution ordinaire
======================

$ ruby ensemble_spec.rb
Run options: --seed 43434

# Running:

........

Finished in 0.001556s, 5140.4367 runs/s, 7068.1005 assertions/s.

8 runs, 11 assertions, 0 failures, 0 errors, 0 skips

-------------------------------------------------------------

======================
Execution ’verbeuse’
======================

$ ruby ensemble_spec.rb -v
Run options: -v --seed 18033

# Running:

Ensemble::#<<#test_0003_retourne self ce qui permet de chainer des operations = 0.00 s = .


Ensemble::#<<#test_0001_ajoute un element lorsque pas deja present = 0.00 s = .
Ensemble::#<<#test_0002_laisse l’element ajoute lorsque deja present = 0.00 s = .
Ensemble::#contient?#test_0001_retourne faux quand un element n’est pas present = 0.00 s = .
Ensemble::#contient?#test_0002_retourne vrai apres qu’un element ait ete ajoute = 0.00 s = .
Ensemble::#cardinalite#test_0001_retourne 0 lorsque vide = 0.00 s = .
Ensemble::#cardinalite#test_0002_retourne 1 lorsqu’un seul et meme element est ajoute,\
1 ou plusieurs fois = 0.00 s = .
Ensemble::#cardinalite#test_0003_retourne le nombre d’elements distincts peu importe\
le nombre de fois ajoutes = 0.00 s = .

Finished in 0.001686s, 4745.7382 runs/s, 6525.3900 assertions/s.

8 runs, 11 assertions, 0 failures, 0 errors, 0 skips


Ruby 217

Remarques et explications pour l’exemple Ruby 4.76 :


• L’exécution en mode ordinaire affiche simplement, comme en JUnit, un «.»
pour chaque cas de test exécuté — chaque utilisation de la méthode de test
«it» — suivi d’un sommaire d’exécution indiquant le nombre de tests exé-
cutés (8 runs), le nombre d’assertions évaluées (11 assertions), le nombre
d’échecs (i.e., de tests pour lesquels certaines assertions n’étaient pas valides)
et d’erreurs (erreurs d’exécution), etc.
• L’exécution en mode «verbeux» (option d’exécution «-v») affiche, pour chaque
cas de test, le nom du test et le temps d’exécution, suivi du même sommaire.
On remarque que le nom complet d’un test est formé de la concaténation des
identificateurs et chaînes des describe englobant, suivi de «#test_», suivi
d’un numéro unique au describe courant, suivi de la chaîne utilisée comme
argument à it.

Exemple Ruby 4.77 Un exemple d’exécution de la suite de tests pour la classe


Ensemble avec des échecs — la méthode cardinalite retourne toujours 0.
======================
Execution avec echecs
======================
$ ruby ensemble_spec.rb
Run options: --seed 7910

# Running:

...FF...

Finished in 0.001950s, 4101.7438 runs/s, 5127.1797 assertions/s.

1) Failure:
Ensemble::#cardinalite#test_0002_retourne 1 lorsqu’un seul et meme element est ajoute,\
1 ou plusieurs fois [ensemble_spec.rb:54]:
Expected: 1
Actual: 0

2) Failure:
Ensemble::#cardinalite#test_0003_retourne le nombre d’elements distincts peu importe\
le nombre de fois ajoutes [ensemble_spec.rb:62]:
Expected: 2
Actual: 0

8 runs, 10 assertions, 2 failures, 0 errors, 0 skips


Ruby 218

Remarques et explications pour l’exemple Ruby 4.77 :

• Dans cet exemple d’exécution, la méthode cardinalite a été modifiée pour


toujours retourner 0. On voit alors, en cours d’exécution, que certains cas de
tests échouent — un «F» est affiché plutôt qu’un «.». Les détails des tests
échoués sont ensuite affichés.

La figure 4.8 (p. 219) présente la liste détaillée des expectations de MiniTest.
Ruby 219

Figure 4.8: La liste des expectations disponibles dans MiniTest. Source :


http://ruby-doc.org/stdlib-2.1.0/libdoc/minitest/rdoc/MiniTest/
Expectations.html.
Ruby 220

Exemple Ruby 4.78 Quelques autres méthodes de MiniTest — dans le style avec
expectations.
gem ’ minitest ’
require ’ minitest / autorun ’
require ’ minitest / spec ’

describe Array do
let (: vide ) { Array . new }

before do
@singleton_10 = Array . new << 10
end

describe " . new " do


it " cree un tableau vide lorsque sans argument " do
vide . must_be : empty ?
end
end

describe " # push " do


it " ajoute un element , lequel devient inclu " do
@singleton_10 . must_include 10
end
end

describe " # size " do


it " retourne 0 lorsque vide " do
vide . size . must_equal 0
end

it " retourne 0 lorsque vide ( bis ) " do


vide . size . must_be :== , 0
end

it " retourne > 0 lorsque non vide " do


@singleton_10 . size . must_be : > , 0
end
end
Ruby 221

describe " # to_s " do


it " retourne ’[] ’ lorsque vide " do
vide . to_s
. must_equal " [] "
end

it " retourne les elements separes par des virgules " do


( vide << 10 << 20 << 30). to_s
. must_equal " [10 , 20 , 30] "
end

it " retourne les elements separes par des virgules ( bis ) " do
a = vide << 10 << 20 << 30
virgule = /\ s * ,\ s */

a . to_s
. must_match
/^\[\ s *10#{ virgule }20#{ virgule }30\ s *\] $ /
end
end
end
Ruby 222

Remarques et explications pour l’exemple Ruby 4.78 :

• Dans l’étape de setup des tests, lorsqu’on veut définir des objets pouvant être
utilisés dans plusieurs cas de tests, on peut utiliser deux approches :

– Objet défini avec une expression simple : on peut utiliser let, qui reçoit
comme argument un Symbol (arg. explicite) et un bloc (arg. implicite).
On peut ensuite utiliser directement l’identificateur dans les tests.
– Objet plus complexe (ou grand nombre d’objets) : on utilise la méthode
before. Dans ce cas, les objets sont des variables d’instance du test,
donc doivent leurs noms doivent être précédés du sigil «@».

Dans les deux formes, les objets ainsi définis sont évalués/recréés pour chaque
cas de test, et ce dans le but d’assurer l’indépendance de chacun des tests.

• La méthode must_be prend comme premier argument un symbole dénotant


un nom de méthode, laquelle sera appelée sur l’objet testé. Un deuxième
argument peut aussi être fourni si la méthode associée au symbole prend un
argument.

• La méthode must_match est utile pour vérifier la représentation textuelle


d’objets, en vérifiant certains élements essentiels et en ignorant des détails
secondaires — dans l’exemple, on ignore le fait qu’il pourrait y avoir 0, 1 ou
plusieurs blancs avant/après les virgules, mais on veut que les virgules soient
présentes entre les éléments du tableau.
Ruby 223

4.C Règles de style Ruby


Pourquoi des conventions sur le style de programmation sont importantes :
• 80% of the lifetime cost of a piece of software goes to maintenance.
• Hardly any software is maintained for its whole life by the original
author.
• Code conventions improve the readability of the software, allowing
engineers to understand new code more quickly and thoroughly.
http: // www. oracle. com/ technetwork/ java/ index-135089. html
Plusieurs auteurs présentent des «règles de style» pour Ruby — donc décrivent
comment écrire du «beau code» Ruby. Une de ces présentations, assez complète,
est disponible à l’adresse suivante :
https://github.com/styleguide/ruby
Les principales règles de style Ruby, inspirées de cette référence, qui ont générale-
ment été utilisées dans les exemples et que vous devriez respecter sont les suivantes :
• Utilisation du snake_case vs. CamelCase :

– NomDeClasse
– NOM_DE_CONSTANTE
– nom_de_methode
– nom_de_parametre_ou_variable

• Indentation avec deux (2) espaces blancs seulement, jamais des carac-
tères de tabulation.
• Jamais de blancs à la fin d’une ligne.
• Des blancs autour des opérateurs binaires (y compris =), après les
virgules, les deux points et les points-virgules, autour des { et avant
les }.
• Pas de blanc avant ou après [ et ], ou après !.
• Jamais de then pour une instruction if/unless et jamais de paren-
thèses autour des conditions (on est en Ruby, pas en Java ou C!) :
# NON # OK
if ( condition ) then if condition
... ...
end end
Ruby 224

• Pas de parenthèses pour une définition de méthode si aucun argu-


ment — idem pour l’appel :
def une_methode_sans_arg
...
end

def une_methode_avec_args ( arg1 , ... , argk )


...
end

# NON
une_methode_sans_arg ()

# OK
une_methode_sans_arg

• Opérateur ternaire ?: seulement pour une expression conditionnelle


simple, non imbriquée, tenant sur une seule ligne.

• On utilise une garde if/unless quand il y a une seule instruction


simple/courte :

# NON # OK
if condition une_instr if condition
une_instr
end

• On utilise unless si la condition est négative (idem pour les gardes). . .


mais on n’utilise pas unless/else :
# NON # OK
if ! expr unless expr
... ...
res res
end end

# NON # OK
unless expr if expr
... si faux ... ... si vrai ...
else else
... si vrai ... ... si faux ...
end end
Ruby 225

• Pour les blocs, on utilise {...} lorsque le corps peut s’écrire sur une
seule ligne. Autrement, on utilise do ... end.
# NON # OK
col . map do | x | ... end col . map { | x | ... }

col . map { | x | col . map do | x |


... ...
} end

• On utilise return seulement pour retourner un résultat au milieu


d’une méthode, pas lorsque l’expression est la dernière évaluée dans
la méthode :
# NON # OK
if expr if expr
... ...
return res res
else else
... ...
return autre_res autre_res
end end

# NON # OK
def m_rec ( ... ) def m_rec ( ... )
if expr return res_base if expr
return res_base
else ...
... res_rec
return res_rec end
end
end

• Dans la définition d’une classe C, on utilise def self.m pour définir


une méthode de classe m, plutôt que def C.m.
Ruby 226

• Pour les objets de classe Hash, on utilise généralement des Symbols


comme clés. Et on utilise la forme avec «=>» :
hash = {
: cle1 = > defn1 ,
: cle2 = > defn2 ,
...
: clek = > defnk
}

Quelques remarques additionnelles concernant le style utilisé dans les exemples :

• Des espaces sont mis autour des parenthèses des définitions de méthodes, con-
trairement à ce qui est suggéré dans ce guide :
# Style suggere dans le guide .
def methode (a , b , c )
...
end

# Style dans le materiel de cours


def methode ( a , b , c )
...
end
Ruby 227

Quelques règles additionnelles


Les règles de style qui suivent sont basées sur des erreurs typiques rencontrées lors
de la correction de devoirs.

• Les méthodes map (collect), select (find_all), reject doivent être utili-
sées pour produire une nouvelle collection, et non pour leurs effets de bord.
Notamment, voici un exemple à ne pas faire s’il est demandé d’utiliser le style
fonctionnel :
# NON
res = []
a . map { | x | res << foo ( x ) }

# OK
a . map { | x | foo ( x ) }

Dans cet exemple, le résultat produit par le map n’est pas utilisé. Le map est en
fait utilisé comme «un faux each», donc n’est pas dans un style fonctionnel.

• On utilise une instruction avec garde — if ou unless après l’instruction —


seulement si l’instruction s’écrit facilement sur une seule ligne :
instr if condition # OK si instr courte .

Si l’instruction est trop longue pour la ligne, alors on utilise la forme avec une
instruction if :
if condition
instruction
end

• Il faut éviter les effets de bord dans les gardes — i.e., la condition d’une garde
ne devrait rien modifier :
puts x if x = ARGV . shift # NON !

Dans certains cas simples, on peut accepter une affectation en début d’une
instruction if si l’effet de bord est bien visible au tout début du code :
if x = ARGV . shift
puts x
end
Ruby 228

• On utilise une instruction avec garde seulement si le cas complémentaire n’a


pas besoin d’être traité, par exemple, si on peut retourner un résultat (ou
signaler une erreur) de façon immédiate. Donc, le segment de code qui suit
n’est pas approprié (NON!) :
instr1 if condition
instr2 unless condition # NON !

Autrement, on utilise plutôt une instruction if :


if condition
instr1
else
instr2
end

• Il est correct d’enchainer plusieurs appels de méthodes. Toutefois, si l’instruction


résultante est longue, alors on met les appels sur plusieurs lignes :
# OK seulement si * tres * court
res = a . select { | x | ... }. map { | x | ... }. sort . join

# Preferable si relativement long ---


# plus facile a lire , modifier , ajouter un autre appel , etc .
res = a . select { | x | ... }
. map { | x | ... }
. sort
. join

• Dans le bloc transmis à reduce, la mise à jour de l’accumulateur se fait im-


plicitement :
# NON
(1.. n ). reduce (1.0) { | res , x | x == 0 ? res : res /= x }

# OK
(1.. n ). reduce (1.0) { | res , x | x == 0 ? res : res / x }

Note : La 1ere expression fonctionne parce que :


res /= x
# est la meme chose que
res = res / x

# et parce que
( res = v ) == v
Ruby 229

4.D Méthodes attr_reader et attr_writer

Exemple Ruby 4.79 Une définition des méthodes attr_reader et attr_writer.


class Class
def attr_reader ( attr )
self . class_eval "
def #{ attr }
@ #{ attr }
end
"
end

def attr_writer ( attr )


self . class_eval "
def #{ attr }=( v )
@ #{ attr } = v
end
"
end
end

class Foo
attr_reader : bar
attr_writer : bar

def initialize
self . bar = 0
end
end

foo = Foo . new


foo . bar += 3
Ruby 230

Exemple Ruby 4.80 Une autre définition des méthodes attr_reader et


attr_writer.
class Class
def attr_reader_ ( attr )
self . class_eval do
define_method attr do
instance_variable_get " @ #{ attr } "
end
end
end

def attr_writer_ ( attr )


self . class_eval do
define_method " #{ attr }= " do | v |
instance_variable_set ( " @ #{ attr } " , v )
end
end
end
end

class Foo
attr_reader : bar
attr_writer : bar

def initialize
self . bar = 0
end
end

L’exemple Ruby 4.79 montre une définition possible des méthodes attr_reader et
attr_writer. Comme c’est souvent le cas en Ruby, il y a plusieurs façons différentes
d’obtenir le même résultat. L’exemple Ruby 4.80 montre donc une autre définition
possible de ces mêmes méthodes.
Ruby 231

4.E Interprétation vs. compilation

Soit l’affirmation suivante : «Ruby est un langage interprété».


Cette affirmation est-elle vraie ou fausse?
Exercice 4.12: Ruby, un langage interprété?

Pourquoi les performances d’un programme Ruby sont-elles généralement moins


bonnes (programme plus lent /) que celles d’un programme Java?
Exercice 4.13: Performances de Ruby.
Partie III

Programmation parallèle en mémoire


partagée
avec Ruby/PRuby

232
Chapitre 5

Patrons de programmation parallèle


avec PRuby

5.1 La bibliothèque PRuby


Ce chapitre présente cinq principaux (5) patrons de base de la programmation par-
allèle :
1. Parallélisme fork/join

2. Parallélisme de boucles

3. Parallélisme de données

4. Parallélisme «Coordonnateur/Travailleurs»

5. Parallélisme de flux de données


Ces patrons de programmation — ces «constructions» pour écrire des pro-
grammes parallèles — sont représentatifs de ce qu’on retrouve dans divers langages
de programmation modernes.
Dans le présent chapitre, ces patrons sont exprimés en PRuby. PRuby est une
bibliothèque — un gem — pour la programmation parallèle en Ruby.
Un point important à signaler est que la bibliothèque PRuby n’a pas été conçue
pour la programmation parallèle haute performance / En fait, Ruby n’est
pas lui-même un langage qui vise les performances du code ; Ruby vise plutôt à
améliorer. . . les performances de la personne qui écrit le programme , En ce sens,
la bibliothèquePRuby peut donc être vue comme une forme de pseudocode paral-
lèle exécutable permettant d’exprimer les principales approches de programmation
parallèle.

233
Patrons de programmation en PRuby 234

Dans d’autres chapitres, on verra d’autres langages conçus spécifiquement pour


la programmation parallèle.

5.2 Parallélisme fork–join : pcall et future


fork–join model : (computer science) A method of programming on par-
allel machines in which one or more child processes branch out from
the root task when it is time to do work in parallel, and end when
the parallel work is done.
Source : http: // www. answers. com/ topic/ fork-join-model

Dans un programme utilisant le parallélisme de type «fork–join», le programme


débute son exécution avec un unique thread — parfois appelé le «thread maître».
Ensuite, ce premier thread crée explicitement de nouveaux threads — avec fork
ou une instruction équivalente. Puis, si nécessaire, le thread «parent» attend que
les threads enfants aient terminé avant de poursuivre son exécution. De plus, les
threads «enfants» peuvent eux aussi créer de nouveaux threads — et aussi attendre
que leurs «enfants» terminent.
Ce modèle tire son nom de la façon dont on crée un processus sur Unix —
avec un appel système fork — et de la façon dont attend la fin d’un thread dans
la bibliothèque de threads Posix — pthread_join. Ces termes ont ensuite été
repris, parfois tels quels, parfois modifiés, dans de nombreux autres langages de
programmation.
Comme on le verra, dans certains langages, tant le fork que le join — ou des
opération équivalentes — sont explicites — par ex., avec les threads de base Posix,
Java ou Ruby — ce qui permet de créer des structures non imbriquées d’appels de
threads. Dans d’autres langages, par contre, l’équivalent du fork est explicite mais
l’opération join est implicite, ce qui ne permet que la création imbriquée de threads.
Dans ce dernier cas, on parle parfois d’une approche avec cobegin/coend. C’est
ce style que nous allons voir en premier avec la méthode pcall de la bibliothèque
PRuby.
L’approche fork-join est un mécanisme de base qu’on retrouve dans plusieurs
langages :

• Processus Unix avec fork et wait/waitpid.

• Threads Posix avec pthread_create et pthread_join.

• Threads Java avec start() et join().

• Threads Ruby avec new et join.


Patrons de programmation en PRuby 235

• Threads Cilk avec spawn et sync.

• Etc.

5.2.1 Factoriel
Comme premier exemple, nous allons définir dDifférentes versions d’une fonction
pour calculer la factorielle d’un nombre n — fact( n ) = 1×2×3×. . .×(n−1)×n.

Programme Ruby 5.1 — Version récursive séquentielle. . . et


linéaire

Programme Ruby 5.1 Fonction récursive séquentielle (linéaire) pour calculer


fact(n).
def fact ( n )
if n == 0
1
else
n * fact ( n - 1 )
end
end

Le programme Ruby 5.1 présente une première version récursive séquentielle et


linéaire. Ici, linéaire signifie qu’un seul appel récursif est effectué, ce qui ne per-
met donc aucun parallélisme /

Peut-on extraire du parallélisme de cet algorithme, tel que formulé?


(Indice : Arbre d’activation = ?)
Exercice 5.1: Extraction de parallélisme de fact?
Patrons de programmation en PRuby 236

Programme Ruby 5.2 — version récursive séquentielle. . . et


dichotomique

Programme Ruby 5.2 Fonction récursive séquentielle (dichotomique) pour cal-


culer fact(n).
def fact ( n )

# Fonction auxiliaire interne :


# fact ( n ) = fact_ (1 , n ).
def fact_ ( i , j )
if i == j
i
else
mid = ( i + j ) / 2
r1 = fact_ ( i , mid )
r2 = fact_ ( mid + 1 , j )

r1 * r2
end
end

fact_ ( 1 , n )
end

Le programme Ruby 5.2 présente une version récursive séquentielle et dichotomique :


dans le cas récursifs, deux (2) appels récursifs à fact sont effectués. Ces appels étant
indépendants l’un de l’autre, ils peuvent donc être exécutés en parallèle ,
On remarque qu’une fonction auxiliaire est introduite — fact_. Cette fonction
assure que les sous-problèmes sont similaires au problème initial — pour que les
appels récursifs puissent avoir des arguments équivalents à l’appel de la fonction :
voir Annexe A.1.
Patrons de programmation en PRuby 237

Programme Ruby 5.3 — version récursive parallèle avec pcall

Programme Ruby 5.3 Fonction récursive parallèle pour calculer fact(n).


def fact ( n )
def fact_ ( i , j )
# Cas de base = probleme trivial (1 seul element ).
return i if i == j

# Cas recursif pour probleme plus complexe :


# solution parallele et recursive
r1 , r2 = nil , nil
mid = ( i + j ) / 2

PRuby . pcall ( lambda { r1 = fact_ (i , mid ) } ,


lambda { r2 = fact_ ( mid + 1 , j ) } )

r1 * r2
end

fact_ ( 1 , n )
end

Le Programme Ruby 5.3 présente une version récursive parallèle avec pcall. Quelques
remarques :
• Plutôt qu’avoir un if avec une branche then et une branche else complexe
imbriquée et indentée, on retourne immédiatement et directement le résultat
approprié dans le cas de base : «return i if i == j».
• Les deux appels récursifs, au lieu d’être faits l’un après l’autre, sont faits en
parallèle, et ce en utilisant la méthode pcall.
Plus précisément, la méthode pcall de la bibliothèque PRuby — méthode
de classe (statique) — reçoit en argument deux ou plusieurs lambdas. Ces
différents lambdas sont alors évalués de façon concurrente — en parallèle si les
ressources le permettent. De plus, l’appel à pcall ne se termine que lorsque
tous les appels des lambdas ont complété. Un appel à la méthode pcall est
donc bloquant pour le thread appelant et introduit une forme de barrière de
synchronisation entre les threads exécutant les lambdas.
• Avant de faire les appels récursifs, on affecte les deux variables r1 et r2 à
nil — on aurait aussi pu écrire «r1 = r2 = nil». Ces affectations sont
Patrons de programmation en PRuby 238

nécessaires pour que les variables r1 et r2 indiquées dans les lambda soient
déjà déclarées et visibles, et donc puissent être modifiées pour obtenir les
résultats des appels récursifs : dans un lambda, si une variable non-locale
existe dans l’environnement, alors c’est cette variable qui est utilisée.

• Une expression lambda peut aussi être dénotée par le symbole «->». De plus,
dans un appel de méthode, les parenthèses, à moins qu’il n’y ait ambiguité à
cause de la précédence des opérateurs, peuvent être omises. L’appel à pcall
aurait donc pu être écrite de différentes façons comme suit — le caractère «\»
indique une continuation de l’instruction à la ligne suivante :
PRuby . pcall \
lambda { r1 = fact (i , mid , seuil ) } ,
lambda { r2 = fact ( mid +1 , j , seuil ) }

PRuby . pcall lambda { r1 = fact (i , mid , seuil ) } ,


lambda { r2 = fact ( mid +1 , j , seuil ) }

PRuby . pcall -> { r1 = fact (i , mid , seuil ) } ,


-> { r2 = fact ( mid +1 , j , seuil ) }

...

• La méthode pcall peut être vue comme une forme de cobegin/codend. Dans
un langage comme MPD, on aurait écrit quelque chose comme suit — co =
cobegin = fork et oc = coend = join — donc avec une syntaxe différente
du pcall mais avec un comportement semblable :

co
r1 = fact_( i, mid )
// r2 = fact_( mid+1, j )
oc
Patrons de programmation en PRuby 239

Programme Ruby 5.4 — version récursive parallèle avec Ruby.pcall


et avec un seuil de récursion

Programme Ruby 5.4 Fonction récursive parallèle pour calculer fact(n) avec
troncation de la récursion.
def fact ( n , seuil )
def fact_ ( i , j , seuil )
# Probleme simple , mais non trivial
# = > solution iterative sequentielle .
return ( i .. j ). reduce (:*) if j - i <= seuil

# Probleme complexe = >


# solution recursive parallele .
r1 , r2 = nil , nil
mid = ( i + j ) / 2

PRuby . pcall ( lambda { r1 = fact_ (i , mid , seuil ) } ,


lambda { r2 = fact_ ( mid + 1 , j , seuil ) } )

r1 * r2
end

fact_ ( 1 , n , seuil )
end

Le Programme Ruby 5.4 présente une version légèrement modifiée de la fonction


précédente. Quelques remarques :
• Dans une approche diviser-pour-régner, lorsque le problème est suffisamment
«simple», on le résout directement — le «cas de base». Dans les exemples
vus jusqu’à présent, on considérait qu’un problème était simple lorsqu’il était
trivial — de taille 1, i.e., i == j.

• Effectuer des appels récursifs entrainent certains coûts. Ces coûts sont encore
plus élevés lorsque ces appels récursifs sont effectués en parallèle, avec des
threads / (voir séance de laboratoire).

• Une façon intéressante, et relativement facile, d’améliorer les performances


d’un algorithme récursif est de terminer la récursion lorsque le problème est
suffisamment simple mais sans être trivial. C’est ce qui est fait ici : lorsque
le nombre dééléments à multiplier est suffisamment petit — j - i <= seuil
Patrons de programmation en PRuby 240

>> [10 , 20 , 30 , 40]. reduce { |x , y | x + y }


= > 100

>> [10 , 20 , 30 , 40]. reduce (999) { |x , y | x + y }


= > 1099

>> []. reduce { |x , y | x + y }


= > nil

>> []. reduce (0) { |x , y | x + y }


=> 0

>> [10 , 20 , 30 , 40]. reduce (999 , &:+)


= > 1099

>> [10 , 20 , 30 , 40]. reduce (999 , :+)


= > 1099

>> (10..20). reduce ( ’ ’) { |x , y | x + ’ , ’ + y . to_s }


= > ’ , 10 , 11 , 12 , 13 , 14 , 15 , 16 , 17 , 18 , 19 , 20 ’

Figure 5.1: Exemples d’exécution de la méthode Ruby reduce (du module


Enumerable).
Patrons de programmation en PRuby 241

— alors on effectue le produit i * (i+1) * ... * (j-1) * j avec reduce,


donc sans faire d’appels récursifs additionnels. Voir Figure 5.1 pour des ex-
emples illustrant la méthode reduce.

Pour le Programme Ruby 5.4, dessinez l’arbre d’activation des appels de méthodes
qui génèrent des threads pour l’appel fact( 20, 3 ).
Exercice 5.2: Arbre d’activation pour l’appel à fact(20, 3).

Programme Ruby 5.5 — Version récursive parallèle avec des


futures

Programme Ruby 5.5 Fonction récursive parallèle pour calculer fact(n) avec
des futures.
def fact ( n , seuil )
def fact_ ( i , j , seuil )
# Probleme simple .
return ( i .. j ). reduce (:*) if j - i <= seuil

# Probleme complexe .
mid = ( i + j ) / 2
r1 = PRuby . future { fact_ (i , mid , seuil ) }
r2 = PRuby . future { fact_ ( mid + 1 , j , seuil ) }

r1 . value * r2 . value
end

fact_ ( 1 , n , seuil )
end

Le Programme Ruby 5.5 présente une autre version du calcul de factoriel avec un
seuil de récursion :
• Dans le Programme Ruby 5.4 (idem pour le Programme Ruby 5.3), pour que
les threads enfants puissent retourner un résultat au thread parent, le par-
ent doit déclarer une variable appropriée, laquelle est ensuite utilisée comme
variable non-locale du lambda. Or, ceci complexifie inutilement le lancement
d’un thread exécuté pour évaluer une expression et retourner un résultat —
par opposition à un thread exécuté pour ses effets de bord. L’utilisation de
Patrons de programmation en PRuby 242

future permet de simplifier ce cas où un thread est utilisé pour évaluer une
expression, ce qui permet de rendre la version parallèle semblable à la version
séquentielle.

• Un appel à la méthode PRuby.future reçoit un (1) bloc — ou un (1) lambda —


qui sera évalué en parallèle. Contrairement à un appel à pcall, on ne spécifie
qu’un seul segment de code à évaluer en parallèle. De plus, l’appel à future
ne bloque pas, mais retourne (quasi)immédiatement un future (un objet de
classe PRubyFuture). Le thread appelant peut donc poursuivre son exécution.
Lorsqu’il a besoin du résultat associé au future, il doit alors faire appel à la
méthode value. C’est cette dernière qui est possiblement bloquante : si le
bloc a terminé son exécution et a produit son résultat, alors l’appel à value
retourne immédiatement cette valeur, sans bloquer; par contre, si le résultat
n’est pas encore disponible, alors l’appel à value bloque jusqu’à ce que le
future ait terminé et retourné son résultat.

Le Programme Ruby 5.5 crée deux (2) futures pour évaluer les deux appels récursifs
en parallèle.

Peut-on améliorer ce programme pour créer des threads de granularité plus grossière
— donc réduire le nombre de threads créés — tout en restant autant parallèle?

Indice : Que fait le thread parent pendant que ses enfants — les deux appels
récursifs — s’exécutent?

Exercice 5.3: Fonction récursive parallèle pour calculer fact(n), mais avec le
thread parent qui fait du travail utile.

Dessinez l’arbre d’activation des threads pour l’appel fact( 20, 3 ) pour la version
améliorée de fact produite pour l’exercice précédent.
Exercice 5.4: Arbre d’activation des threads pour l’appel à fact(20, 3) avec
version améliorée de fact.

5.2.2 Somme de deux tableaux


Dans cet exemple, nous allons définir une fonction pour effectuer la somme de deux
tableaux — deux vecteurs, deux Arrays. Le Programme Ruby 5.6 présente une
version séquentielle et itérative d’une telle fonction. La fonction reçoit en arguments
les tableaux a et b et retourne un tableau c qui représente leur somme, élément par
élément (element-wise).
Patrons de programmation en PRuby 243

Programme Ruby 5.6 Fonction séquentielle itérative pour faire la somme de deux
tableaux.
def somme_tableaux ( a , b )
DBC . require a . size == b . size # Precondition : omise ailleurs .

c = Array . new ( a . size )

(0... c . size ). each do | k |


c[k] = a[k] + b[k]
end

c
end

Quelques remarques :

• Une précondition a été indiquée pour assurer que les deux tableaux sont de
même taille. Dans les versions qui suivent de cette fonction, cette précondition
sera omise pour simplifier le code présenté.

• Les index d’un tableau a vont de 0 à a.size-1 inclusivement. Ce sont donc


sur ces index que l’on itère, avec each : rappelons que le Range 0...k (borne
exclusive) dénote les mêmes éléments que 0..k-1 (borne inclusive).

Programme Ruby 5.7 Fonction parallèle itérative à granularité fine pour faire la
somme de deux tableaux avec pcall.
def somme_tableaux ( a , b )
c = Array . new ( a . size )

PRuby . pcall ( 0... c . size ,


lambda { | k | c [ k ] = a [ k ] + b [ k ] }
)

c
end

Le Programme Ruby 5.7 présente une version parallèle utilisant pcall. La som-
mation pour chacun des éléments peut se faire de façon totalement indépendante — il
s’agit d’une forme de parallélisme dite «embarrassingly parallel ». Il est donc possible
Patrons de programmation en PRuby 244

de créer un thread distinct pour chacun des éléments, et c’est ce qui est fait ici. Dans
ce cas, on parle aussi de parallélisme à granularité (très!) fine, puisque chaque thread
n’effectue qu’un tout petit calcul, exécute un tout petit nombre d’instructions. La
Figure 5.2 illustre le graphe de dépendances des tâches pour des tableaux de taille n
(variante d’un graphe vu précédemment).
Remarques additionnelles :

• L’instruction pcall reçoit ici deux (2) arguments :

– Le premier argument est un Range, donc un intervalle d’index.


– Le deuxième argument est une lambda-expression avec un (1) argument,
qui sera une des valeurs du Range.

• Une telle instruction pcall crée une famille de threads, indexée par les élé-
ments du Range. Avec pcall, on peut donc lancer l’exécution de plusieurs
threads, soit en spécifiant deux ou plusieurs lambda sans argument, soit en
spécifiant un Range et une lambda avec un argument (entier).

Figure 5.2: Graphe de dépendances des tâches pour le calcul parallèle de la somme
de deux tableaux.

Est-ce que cette méthode sera performante si on traite deux gros tableaux?
Exercice 5.5: Performances si deux gros tableaux?
Patrons de programmation en PRuby 245

Programme Ruby 5.8 Fonction parallèle itérative à granularité grossière pour


faire la somme de deux tableaux avec pcall.
def somme_tableaux ( a , b , nb_threads = PRuby . nb_threads )
DBC . require a . size == b . size && a . size % nb_threads == 0

# Les indices pour la tranche du thread no . k .


def indices_tranche ( k , n , nb_threads )
( k * n / nb_threads )..(( k + 1) * n / nb_threads - 1)
end

# Somme sequentielle de la tranche pour indices ( inclusif )


def somme_seq ( a , b , c , indices )
indices . each { | k | c [ k ] = a [ k ] + b [ k ] }
end

# On alloue le tableau pour le resultat .


c = Array . new ( a . size )

# On active les divers threads ,


# en specifiant les indices de la tranche a traiter .
PRuby . pcall ( 0... nb_threads ,
lambda do | k |
inds = indices_tranche (k , c . size , nb_threads )
somme_seq ( a , b , c , inds )
end
)

# On retourne le resultat .
c
end

Créer des threads coûte cher et en créer un pour chaque élément d’un tableau
ne sera assurément pas performant. Ainsi, si l’on doit additionner 1000 éléments
sur une machine avec 4 processeurs, on créera donc 1000 threads, dont l’exécution
devra ensuite être répartie entre les 4 processeurs. Beaucoup de travail pour rien!
Pour obtenir un programme performant, il est généralement préférable d’utiliser
un nombre de threads qui soit du même ordre de grandeur que le nombre de pro-
cesseurs. Ici, cela signifie que chaque thread devrait prendre en charge la somme de
plusieurs éléments, et non pas un seul.
Patrons de programmation en PRuby 246

Le Programme Ruby 5.8 présente une telle fonction, dite «à granularité grossière» :

• Un argument additionnel, mais optionnel, a été ajouté à la fonction somme_tableaux,


argument permettant de spécifier combien de threads doivent être utilisés. Par
défaut, on utilise comme nombre de threads la valeur PRuby.nb_threads, qui
est égale au nombre de processeurs (ou coeurs) de la machine.

• Chaque thread est responsable de calculer une tranche d’éléments adjacents,


calcul qui se fait de façon séquentielle et itérative dans la méthode somme_seq.

• Les indices associés à une tranche pour un thread sont obtenus avec la fonction
indices_tranche, qui retourne un Range. Par exemple, si on a 100 éléments
(a.size == 100) et 4 threads (nb_threads == 4), alors les tranches des dif-
férents threads seront réparties comme suit :
– thread 0 : 0..24
– thread 1 : 25..49
– thread 2 : 50..74
– thread 3 : 76..99

Pour que cette façon simple de déterminer les indices à traiter fonctionne
correctement, il faut que le nombre d’eléments à traiter soit un multiple du
nombre threads. Cette condition est vérifiée dans la précondition. On verra
ultérieurement comment calculer ces intervalles d’indices sans imposer cette
condition. On verra aussi d’autres façons de répartir les éléments à traiter
entre les divers threads.
Dans le cas plus général, donc avec k threadset n éléments, voir la figure 5.3
pour la répartition résultante.

• La stratégie utilisée ici consiste donc à répartir un grand nombre d’éléments


à traiter entre un petit nombre de threads en groupant ces éléments par blocs
(tranches) d’éléments adjacents. Cette stratégie est fréquemment utilisée. On
verra plus loin (section 5.3.1) que PRuby permet de l’exprimer de façon beau-
coup plus simple.
Patrons de programmation en PRuby 247

Figure 5.3: Répartition par blocs d’éléments adjacent de n éléments entre k threads.
Chaque thread traite n/k éléments adjacents — pour simplifier, on suppose que n
est divisible par k.La figure du haut présente une distribution d’un tableau, alors
que la figure du bas présente le graphe de dépendances des tâches pour la somme
de deux tableaux mais où un bloc de tâches est attribué à chacun des threads.
Patrons de programmation en PRuby 248

5.2.3 Calcul de π à l’aide d’une méthode Monte Carlo


Un problème avec «parallélisme embarrassant», tel que celui de la somme de deux
tableaux, peut être décomposé en un ensemble de tâches qui sont complètement
indépendantes.
Pour d’autres problèmes, dits avec «parallélisme semi-embarrassant», le
problème peut aussi être décomposé en un grand nombre de tâches qui sont indépen-
dantes. . . mais pas tout à fait complètement : les tâches interagissent, mais de
façon limitée, et souvent uniquement vers la fin de l’exécution des tâches.
Un problème relativement simple ayant cette propriéte est celui visant à estimer
la valeur de π à l’aide d’une méthode Monte Carlo. Cette méthode consiste à
effectuer des essais «au hasard» — donc à l’aide de nombres pseudo-aléatoires —
dans le but de trouver une solution au problème, solution parfois approximative
mais «pas trop mauvaise».

Figure 5.4: Estimation de la valeur de π à l’aide d’une méthode Monte Carlo (source :
http://i.stack.imgur.com/uYrT5.jpg).

La figure 5.4 illustre comment on peut estimer la valeur de π à l’aide d’une telle
méthode. Supposons qu’on ait une cible carrée de taille 2 par 2 dans laquelle est
Patrons de programmation en PRuby 249

inscrit un cercle de rayon 1.


Le rapport entre la surface du carré et celle du cercle est défini comme suit :
• Aire du cercle de rayon r = 1 : Acercle = πr2 = π
• Aire du carré de coté 2 : Acarré = (2)2 = 4
Acercle π
• Rapport cercle sur carré : Acarré
= 4

• Valeur de π : π = 4 AAcercle
carré

Comment peut-on alors estimer la valeur de π? Supposons «qu’on lance au


hasard » des flèchettes sur cette cible carrée. Si le nombre de fléchettes lancées est
assez grand, alors on pourra estimer la valeur de π comme suit :
nb_total_dans_le_cercle
π ≈4×
nb_de_lancers

Figure 5.5: Estimation de la valeur de π à l’aide d’une méthode Monte Carlo : La


méthode présentée travaille exclusivement sur le quadrant supérieur droit.
Patrons de programmation en PRuby 250

>> [10 , 20 , 30 , 40]. map { | x | 2 * x }


=> [20 , 40 , 60 , 80]

>> []. map { | x | 2 * x }


=> []

>> a = (0...10). map { | i | 10* i +1 }


= > [1 , 11 , 21 , 31 , 41 , 51 , 61 , 71 , 81 , 91]

>> a . map { | x | 10 * x }
=> [10 , 110 , 210 , 310 , 410 , 510 , 610 , 710 , 810 , 910]
>> a
=> [1 , 11 , 21 , 31 , 41 , 51 , 61 , 71 , 81 , 91]

>> a . map ! { | x | 10 * x }
=> [10 , 110 , 210 , 310 , 410 , 510 , 610 , 710 , 810 , 910]
>> a
=> [10 , 110 , 210 , 310 , 410 , 510 , 610 , 710 , 810 , 910]

>> -3. abs


=> 3
>> [ -10 , 10 , 20 , -2 , 0]. map (&: abs )
= > [10 , 10 , 20 , 2 , 0]
>> [ -10 , 10 , 20 , -2 , 0]. map &: abs
= > [10 , 10 , 20 , 2 , 0]
>> [ -10 , 10 , 20 , -2 , 0]. map (: abs )
ArgumentError : wrong number of arguments calling ‘map ‘ (1 for 0)
from ( irb ):6: in ’ evaluate ’
...

Figure 5.6: Exemples d’exécution de la méthode Ruby map (du module Enumerable).
Patrons de programmation en PRuby 251

Programme Ruby 5.9 Fonction parallèle pour estimer la valeur de π à l’aide d’une
méthode Monte Carlo (style fonctionnel).
def nb_dans_cercle_seq ( nb_lancers )
nb = 0
nb_lancers . times do
# On genere un point aleatoire .
x , y = rand , rand

# On incremente s ’ il est dans le cercle


nb += 1 if x * x + y * y <= 1.0
end

nb
end

def evaluer_pi ( nb_lancers , nb_threads = PRuby . nb_threads )


# On active les divers threads en creant des futures .
futures_nb_dans_cercle = (0... nb_threads ). map do
PRuby . future { nb_dans_cercle_seq ( nb_lancers / nb_threads ) }
end

# On recoit et on additionne les resultats des futures .


nb_total_dans_cercle =
futures_nb_dans_cercle
. map ( &: value )
. reduce ( &:+ )

4.0 * nb_total_dans_cercle / nb_lancers


end

Le Programme Ruby 5.9 présente une mise en oeuvre parallèle avec des futures
de cette approche, qui travaille uniquement dans le cadra supérieur droit, tel qu’illustré
dans la Figure 5.5 :

• On crée nb_threads threads, et ce en créant un future pour chaque thread.


Après l’exécution du premier map, futures_nb_dans_cercle est donc un
tableau de futures — très probablement encore en cours d’exécution si le
nombre de lancers est grand.

• Chaque thread simule le lancement de nb_lancers/nb_threads lancers —


donc on répartit uniformément entre les threadsles lancers à effectuer.
Patrons de programmation en PRuby 252

• Chaque thread — une instance de nb_dans_cercle_seq — retourne nb, le


nombre de lancers effectués qui ont abouti dans le cercle.

• La fonction principale appelle la méthode value sur chaque future — donc


peut bloquer en attente d’un résultat — puis effectue la somme de ces valeurs
à l’aide de reduce.

Programme Ruby 5.10 Fonction parallèle pour estimer la valeur de π à l’aide


d’une méthode Monte Carlo, dans un style plus «impératif».
def evaluer_pi ( nb_lancers , nb_threads = PRuby . nb_threads )
# On active les threads en creant des futures .
futures_nb_dans_cercle = []
nb_threads . times do
futures_nb_dans_cercle << PRuby . future do
nb_dans_cercle_seq ( nb_lancers / nb_threads )
end
end

# On recoit les resultats des futures .


les_nbs = []
futures_nb_dans_cercle . each do | f |
les_nbs << f . value
end

# On additionne les resultats intermediaires .


nb_total_dans_cercle = 0
les_nbs . each do | nb |
nb_total_dans_cercle += nb
end

4.0 * nb_total_dans_cercle / nb_lancers


end

Pour ceux plus familiers avec une approche impérative, le Programme Ruby 5.10
présente une version équivalente, mais dans un style plus «impératif». Soulignons
toutefois qu’en Ruby, c’est le Programme Ruby 5.9 qui est considéré comme ayant
«le meilleur style».
Patrons de programmation en PRuby 253

Écrivez une méthode sommation_tableau qui reçoit en argument un tableau a (un


Array) composé de nombres (Numeric) et qui retourne la somme de ces nombres,
par exemple :
sommation_tableau ( [] ).
must_equal 0

sommation_tableau ( [99] ).
must_equal 99

sommation_tableau ( [1 , 20 , 300 , 4000] ).


must_equal 4321
De plus, cette méthode doit utiliser du parallélisme récursif — approche diviser-
pour-régner dichotomique — et doit utiliser la construction PRuby.pcall ou
PRuby.future.

Notez qu’il n’est pas nécessaire d’introduire de troncation de la récursion (avec un


seuil). Vous pouvez donc diviser jusqu’au cas de base trivial.
Exercice 5.6: Sommation des éléments d’un tableau avec parallélisme récursif.

Soit la méthode suivante qui se veut une solution à l’exercice précédent :


def sommation_tableau ( a )
return 0 if a . size == 0
return a [0] if a . size == 1

mid = a . size / 2
r1 = PRuby . future { sommation_tableau ( a [0.. mid -1]) }
r2 = sommation_tableau ( a [ mid ... a . size ] )
r1 . value + r2
end

Que peut-on dire de cette solution?


Exercice 5.7: Sommation des éléments d’un tableau avec parallélisme récursif et
utilisation de tranches de tableaux.
Patrons de programmation en PRuby 254

Comme dans l’exercice précédent, écrivez une méthode sommation_tableau qui


reçoit en argument un tableau a composé de nombres et qui retourne la somme de
ces nombres.

Toutefois, cette méthode doit utiliser du parallélisme itératif à granularité grossière


et doit utiliser la construction PRuby.pcall.

Pour simplifier, vous pouvez supposer que le nombre d’eléments du tableau


est divisible par le nombre de threads. Vous pouvez donc utiliser la fonction
suivante :
def bornes_tranche ( k , n , nb_threads )
b_inf = k * n / nb_threads
b_sup = ( k +1) * n / nb_threads - 1
b_inf .. b_sup
end

Exercice 5.8: Sommation des éléments d’un tableau avec parallélisme itératif à
granularité grossière.
Patrons de programmation en PRuby 255

5.3 Parallélisme de boucles : peach et peach_index


Dans un langage impératif «classique» comme le langage C qui manipule principale-
ment des tableaux, la structure de contrôle de base utilisée pour traiter ces tableaux
est la boucle, plus spécifiquement la boucle for.
Une boucle for est dite «boucle définie», car on connaît le nombre d’itérations
lors du lancement de la boucle — contrairement à une boucle while, dite «boucle
indéfinie», où le nombre d’itérations dépend d’une condition déterminée en cours
d’exécution.
Si les différentes itérations de la boucle for sont indépendantes les unes des
autres — notamment, n’écrivent pas dans les mêmes variables —, alors elles peu-
vent être exécutées en parallèle.
Pour ce faire, de nombreux langages parallèles — dont diverses variantes ou
extensions de C, par ex., OpenMP/C — introduisent des boucles parallèles, sous
différents noms : parallel_for, forall, foreach, etc.
En PRuby, deux méthodes sont disponibles pour exprimer le parallélisme de
boucle, peach et peach_index, des variantes parallèles de each et each_index.
Avant de voir des exemples d’utilisation des versions parallèles, rappelons la
différence entre each et each_index dans le cas des classes Array et Range, les
deux classes pour lesquelles les variantes parallèles sont disponibles en PRuby :

• L’itérateur each reçoit chacun des éléments d’une collection et applique un


bloc à chaque élément reçu.

• L’itérateur each_index reçoit chacun des indices des éléments d’une collection,
et applique un bloc à chaque indice reçu. Cette méthode n’est toutefois valide
que pour un objet Array, et non pour un Range.

Dans les deux cas, la valeur retournée par l’appel à la méthode est la collec-
tion elle-même, i.e., celle sur laquelle s’applique la méthode each ou each_index.
L’exemple Ruby 5.1 présente quelques exemples simples.
Patrons de programmation en PRuby 256

Exemple Ruby 5.1 Différences entre each et each_index pour les Array et Range.
>> [10 , 20 , 30]. each { | x | puts x }
10
20
30
= > [10 , 20 , 30]

>> [10 , 20 , 30]. each_index { | x | puts x }


0
1
2
= > [10 , 20 , 30]

>> (1..3). each { | x | puts x }


1
2
3
= > 1..3

>> (1..3). each_index { | x | puts x }


NoMethodError : undefined method ‘ each_index ’ for 1..3: Range
...
Patrons de programmation en PRuby 257

5.3.1 Somme de deux tableaux

Programme Ruby 5.11 Fonction parallèle itérative à granularité fine pour faire
la somme de deux tableaux avec peach.
def somme_tableaux ( a , b , _nb_threads )
c = Array . new ( a . size )

(0... c . size ). peach ( nb_threads : c . size ) do | k |


c[k] = a[k] + b[k]
end

c
end

Le Programmme Ruby 5.11 présente une version avec parallélisme de boucle à gran-
ularité fine pour la somme de deux tableaux.

Programme Ruby 5.12 Fonction parallèle itérative à granularité grossière pour


faire la somme de deux tableaux avec peach.
def somme_tableaux ( a , b , nb_threads = PRuby . nb_threads )
c = Array . new ( a . size )

(0... c . size ). peach ( nb_threads : nb_threads ) do | k |


c[k] = a[k] + b[k]
end

c
end

Le Programmme Ruby 5.12 quant à lui présente une version avec parallélisme de
boucle à granularité grossière pour la somme de deux tableaux. Comme on le
constate, c’est une version très simple, semblable à la version séquentielle, sauf pour
la spécification du nombre de threads à utiliser. De plus, cette version est tout à fait
équivalente à celle du Programme Ruby 5.8 quant à la répartition des éléments entre
les threads, et ce même si elle est beaucoup plus simple. Quelques explications :

• La méthode peach peut recevoir divers arguments, tous optionnels, arguments


qui sont spécifiés par mot-clé (keyword arguments) :
Patrons de programmation en PRuby 258

– On peut spécifier le nombre de threads à utiliser pour exécuter les diverses


itérations de la boucle.
Par défaut, si aucune valeur n’est spécifiée, on utilise Pruby.nb_threads
threads (nombre de coeurs ou processeurs de la machine), donc :
col . peach { ... }
=
col . peach ( nb_threads : PRuby . nb_threads ) { ... }

• Les threads vont se partager les diverses itérations de la boucle. Ces itérations
peuvent être distribuées de différentes façons :
– Par défaut — ou lorsqu’on indique simplement «static: true» — la
répartition se fait par groupes d’éléments adjacents, comme dans le Pro-
gramme Ruby 5.8 :
col . peach { ... }
=
col . peach ( static : true ) { ... }

Ici, on parle d’une répartition statique parce qu’on sait, dès le lancement des
threads, quelles seront les itérations traitées par chaque thread.
Signalons que contrairement au Programme Ruby 5.8, le nombre d’éléments à
traiter n’a pas besoin d’être divisible par le nombre de threads. Si le nombre
d’éléments n’est pas exactement divisible, les éléments seront répartis le plus
uniformément possible entre les threads. Par exemple, si on doit répartir 20
éléments entre 6 threads, les 2 premiers threads traiteront 4 éléments alors que
les 4 threads suivants en traiteront 3.
Patrons de programmation en PRuby 259

• Lorsqu’ une valeur numérique est spécifiée, par exemple «static: k», on a
alors une répartition statique et cyclique par bloc de k éléments.
Par exemple, si on a 12 éléments à répartir entre 3 threads, on aurait donc les
associations suivantes — ti dans une position du tableau indique que c’est le
thread i qui traite cet élément :

– static: true :
t0 t0 t0 t0 t1 t1 t1 t1 t2 t2 t2 t2
– static: 1 :
t0 t1 t2 t0 t1 t2 t0 t1 t2 t0 t1 t2
– static: 2 :
t0 t0 t1 t1 t2 t2 t0 t0 t1 t1 t2 t2
– static: 3 :
t0 t0 t0 t1 t1 t1 t2 t2 t2 t0 t0 t0
– static: 4 :
t0 t0 t0 t0 t1 t1 t1 t1 t2 t2 t2 t2

• Il est aussi possible de spécifier une répartition dynamique des éléments. On


verra des exemples plus loin.
Patrons de programmation en PRuby 260

5.3.2 Calcul de π à l’aide d’une méthode Monte Carlo

Programme Ruby 5.13 Fonction parallèle pour estimer la valeur de π à l’aide


d’une méthode Monte Carlo et en utilisant un peach.
def evaluer_pi ( nb_lancers , nb_threads = PRuby . nb_threads )
nb_dans_cercle = Array . new ( nb_threads )

(0... nb_threads ). peach ( nb_threads : nb_threads ) do | k |


nb_dans_cercle [ k ] =
nb_dans_cercle_seq ( nb_lancers / nb_threads )
end

nb_total_dans_cercle = nb_dans_cercle . reduce ( &:+ )

4.0 * nb_total_dans_cercle / nb_lancers


end

Le Programme Ruby 5.13 présente une autre version du calcul de π, cette fois en
utilisant un peach. Solution simple, n’est-ce pas? Mais on verra bientôt qu’il est
possible de faire encore plus simple!
Patrons de programmation en PRuby 261

5.3.3 Produit de matrices

Programme Ruby 5.14 Fonction séquentielle pour effectuer le produit de deux


matrices.
def produit ( a , b )
DBC . require a . nb_colonnes == b . nb_lignes

c = Matrice . new ( a . nb_lignes , b . nb_colonnes )

(0... c . nb_lignes ). each do | i |


(0... c . nb_colonnes ). each do | j |
c [i , j ] = 0
(0... a . nb_colonnes ). each do | k |
c [i , j ] += a [i , k ] * b [k , j ]
end
end
end

c
end

Le Programme PRuby 5.14 présente une fonction séquentielle itérative pour calculer
le produit de deux matrices. Ces matrices sont des objets de la classe Matrice. Voir
l’URL suivant pour une description de l’API de cette classe :
http://www.labunix.uqam.ca/~tremblay/INF5171/pruby/Matrice.html
On remarque qu’une précondition sur la taille des matrices à multiplier a été
spécifiée. La même condition s’applique évidemment aux versions parallèles, même
si cette condition ne sera plus indiquée explicitement.
Le Programme PRuby 5.15 présente une version parallèle à granularité très fine
du produit de matrices. Chaque élément du résultat — matrice c — peut être calculé
de façon indépendante, donc avec un algorithme avec parallélisme embarrassant.
Dans cette version, on lance donc un thread pour chaque élément du résultat!
Clairement cette solution risque de ne pas être efficace, car un très (trop!?) grand
nombre de threads seront lancés.
Patrons de programmation en PRuby 262

Programme Ruby 5.15 Fonction parallèle à granularité (très!) fine pour ef-
fectuer le produit de deux matrices.
def produit ( a , b )
c = Matrice . new ( a . nb_lignes , b . nb_colonnes )
nbl = c . nb_lignes # Vars ... pour mise en page
nbc = c . nb_colonnes

(0... nbl ). peach ( nb_threads : nbl ) do | i |


(0... nbc ). peach ( nb_threads : nbc ) do | j |
c [i , j ] = 0
(0... a . nb_colonnes ). each do | k |
c [i , j ] += a [i , k ] * b [k , j ]
end
end
end

c
end

Supposons a de taille n1 × n2 et b de taille n2 × n3 .


Combien de threads seront créés?
Est-ce une bonne idée?
Exercice 5.9: Nombre de threads pour deux matrices.

Le Programme PRuby 5.16 présente une autre version du produit de matrices,


cette fois, avec un thread pour chaque ligne.
Patrons de programmation en PRuby 263

Programme Ruby 5.16 Fonction parallèle à granularité grossière pour effectuer


le produit de deux matrices, avec répartition entre les threads par ligne.
def produit ( a , b )
c = Matrice . new ( a . nb_lignes , b . nb_colonnes )

(0... c . nb_lignes ). peach ( nb_threads : c . nb_lignes ) do | i |


(0... c . nb_colonnes ). each do | j |
c [i , j ] = 0
(0... a . nb_colonnes ). each do | k |
c [i , j ] += a [i , k ] * b [k , j ]
end
end
end

c
end

Question : Combien de threads seront créés?


Finalement, le Programme PRuby 5.17 présente une autre version du produit de
matrices parallèle, cette fois à granularité grossière :

• Un seul peach est utilisé, au niveau des lignes. Pour le traitement des diverses
colonnes d’une ligne, on utilise plutôt un each, donc un traitement itératif
séquentiel.

• Aucun argument n’est spécifié au peach, donc :

– On utilisera un nombre de threads égal à PRuby.nb_threads, soit le


nombre de processeurs de la machine.
– On utilisera une répartition statique adjacente des éléments entre ces
threads — par groupe d’éléments adjacents, comme vu précédemment.

En d’autres mots, chaque thread calculera un bloc de lignes de la matrice résul-


tat c, donc la granularité des threads sera plus grossière.
Patrons de programmation en PRuby 264

Programme Ruby 5.17 Fonction parallèle à granularité encore plus grossière pour
effectuer le produit de deux matrices, avec répartition entre les threads par blocs de
lignes.
def produit ( a , b )
c = Matrice . new ( a . nb_lignes , b . nb_colonnes )

(0... c . nb_lignes ). peach do | i |


(0... c . nb_colonnes ). each do | j |
c [i , j ] = 0
(0... a . nb_colonnes ). each do | k |
c [i , j ] += a [i , k ] * b [k , j ]
end
end
end

c
end

Question : Combien de threads seront créés?

Modes de répartition des matrices entre threads


Certains langages de programmation — par exemple, Fortran 90, HPF (High Per-
formance Fortran) — permettent d’exprimer directement et explicitement le mode
de répartition des tableaux entre les threads ou les processus.
Les figures 5.7 et les figures 5.8–5.9 illustrent différents modes de répartition d’un
tableau à une dimension (figure 5.7) et à deux dimensions (figures 5.8 et 5.9).

Figure 5.7: Distribution par bloc vs. distribution cyclique pour un tableau à
1 dimension (source : http://www.dais.unive.it/~calpar/New_HPC_course/5_
Parallel_Patterns.pdf).
Patrons de programmation en PRuby 265

Figure 5.8: Distribution par bloc vs. distribution cyclique pour une matrice à 2
dimensions (source : http://www.dais.unive.it/~calpar/New_HPC_course/5_
Parallel_Patterns.pdf).
Patrons de programmation en PRuby 266

Figure 5.9: Différents types de distribution des données pour un tableau 8 × 8 entre
quatre (4) processus en HPF. Les données pour le processus 0 sont en gris foncé
(source : [Fos95]). Sur la première ligne : différents modes de répartition par blocs
— dont blocs de lignes (gauche) et blocs de colonnes (centre). Sur la deuxième
ligne : différents modes de répartition cyclique.
Patrons de programmation en PRuby 267

5.4 Parallélisme de données et approche de style


fonctionnel : pmap et preduce
5.4.1 Parallélisme de données
Le parallélisme de données correspond à l’application d’une même opération sur
tous les éléments d’une collection — collection généralement (mais pas toujours)
homogène, c’est-à-dire dont les éléments sont tous du même type.
Habituellement, ces collections de données sont des listes (des séquences) —
dans les langages fonctionnels — ou des tableaux — dans les langages impératifs
plus «mainstream». Dans les deux cas, il s’agit de structures de données régulières et
linéaires — par opposition aux structures de données dynamiques avec des pointeurs
arbitraires qui donnent lieu à des arbres ou graphes (non-linéaires).

5.4.2 Application, réduction et préfixes


Un programme écrit dans le style «parallélisme de données» est généralement com-
posé d’une séquence d’applications d’opérations sur des collections. Les trois prin-
cipales opérations sur les collections sont les suivantes :
• Application : une telle opération consiste à appliquer une fonction sur chacun
des éléments d’une collection pour obtenir un résultat qui est une nouvelle
collection, de taille identique. Dans la terminologie des langages fonctionnels,
on parle alors d’une opération de type map — certains auteurs parlent aussi
d’un schéma de type α-notation ou d’une α-application [GUD96].
Plus spécifiquement, soit f une opération unaire (avec un seul argument) et
C = [c1 , . . . , cn ] une collection de taille n. L’application de f à la collection C
produira alors la collection R (aussi de taille n) définie comme suit :

R = [f (c1 ), . . . , f (cn )]

Il est aussi possible de généraliser à des opérations k-aires — c’est-à-dire avec k


arguments. Soit les k collections C 1 , . . . , C k toutes de taille n. L’application de
g, une fonction de k arguments, sur ces k collections produit alors la collection
R de taille n satisfaisant la propriété suivante :

R = [g(c11 , . . . , ck1 ), . . . , g(c1n , . . . , ckn )]

• Réduction : une telle opération consiste à appliquer une fonction binaire (à


deux arguments) sur les divers éléments de la collection pour obtenir un ré-
sultat qui est une valeur généralement d’un type plus simple — par
Patrons de programmation en PRuby 268

exemple, la réduction d’une collection de nombres avec l’opérateur d’addition


produirait un nombre en résultat. Dans la terminologie des langages fonction-
nels, on parle alors d’un opération de type fold ou reduce — certains auteurs
parlent plutôt de β-réduction [GUD96].
Par exemple, soit ⊕ une opération binaire et C = [c1 , . . . , cn ] une collection.
L’application de ⊕ sur C via une β-réduction produit alors le résultat r définie
comme suit :
r = ((((c1 ⊕ c2 ) ⊕ c3 ) ⊕ . . .) ⊕ cn )

• Calcul de préfixes : une telle opération applique une opération binaire associa-
tive sur les divers éléments de la collection pour obtenir un résultat qui sera une
autre collection de même taille, où le ième éléments de la collection résultante
est obtenu par une série d’applications de l’opération binaire sur les i premiers
éléments. Plus précisément, soit ⊕ une opération binaire et C = [c1 , . . . , cn ]
une collection. Le calcul de préfixes sur C avec ⊕ produira alors le résultat R
satisfaisant la condition suivante, donc tel que Ri = c1 ⊕ c2 ⊕ . . . ⊕ ci :

R = [c1 , c1 ⊕ c2 , c1 ⊕ c2 ⊕ c3 , . . . , c1 ⊕ . . . ⊕ cn−1 , c1 ⊕ . . . ⊕ cn−1 ⊕ cn ]

Les notions d’application avec map et de réduction avec reduce ne sont pas
nouvelles. Le premier langage à introduire de telles opérations fut Lisp, et ce dès
les années 60. Par la suite, de nombreux langages, surtout les langages fonctionnels,
ont défini de telles opérations. De nos jours, la plupart des langages définissent de
telles opérations. L’exemple d’exécution 5.1 présente un exemple d’application et un
exemple de réduction exprimés dans trois langages : Lisp [Ste84], Haskell [Tho96] et
Ruby. De telles opérations sont aussi redevenues (très!) «à la mode» ces dernières
années avec l’introduction du modèle Map/Reduce de Google [DG08], popularisé
notamment grâce à Hadoop [Whi15].

5.4.3 Application et réduction en parallèle


Dans un langage fonctionnel1 , une fonction n’a jamais d’effets de bord — ne peut
pas modifier son environnement. En d’autres mots, si on appelle deux fois la même
fonction avec les mêmes arguments, on obtiendra toujours le même résultat, et ce
sans que l’environnement ne soit modifié.
Les opérations map et reduce introduites par les langages fonctionnels ne visaient
pas nécessairement la programmation parallèle. Par contre, puisque les fonctions
1
En fait, cette affirmation n’est valide que pour les langages purement fonctionnels. C’est le
cas d’Haskell, mais pas de Lisp ou Ruby.
Patrons de programmation en PRuby 269

• Ruby :
[1 , 2 , 3 , 4]. map { | x | 2 * x }
=>
[2 , 4 , 6 , 8]

[1 , 2 , 3 , 4]. reduce (1) { | prod , n | prod * n }


=>
24

• Lisp :

(map ’list #’(lambda (x) (* 2 x)) ’(1 2 3 4))


=>
(2 4 6 8)

(reduce #’* ’(1 2 3 4))


=>
24

• Haskell :

map (2*) [1,2,3,4]


=>
[2,4,6,8]

foldr (*) 1 [1,2,3,4]


=>
24

Exemple d’exécution 5.1: Exemples d’utilisation d’opérations de style map et


reduce dans divers langages de programmation : Ruby, Lisp, Haskell.
Patrons de programmation en PRuby 270

n’ont pas d’effet de bord — donc pas d’interactions entre elles — on en déduit qu’une
application map est naturellement parallèle : toutes les applications de la fonction
aux divers éléments de la collection peuvent se faire en parallèle.
Dans le cas d’une réduction, si la fonction binaire utilisée pour la réduction est
associative — ce qui est habituellement le cas pour les opérations utilisées dans des
telles β-réductions, par exemple, +, ∗, MAX, MIN — alors l’ordre d’évaluation n’a pas
d’importance. On peut donc parenthéser cette expression de façon différente sans
changer le résultat2 . Par exemple, pour l’expression de réduction présentée plus
haut avec n = 8, alors les deux façons suivantes de parenthéser l’expression sont
équivalentes :

((((c1 ⊕ c2 ) ⊕ c3 ) ⊕ . . .) ⊕ c8 ) = (((c1 ⊕ c2 ) ⊕ (c3 ⊕ c4 )) ⊕ ((c5 ⊕ c6 ) ⊕ (c7 ⊕ c8 )))

Figure 5.10: Graphe de dépendances pour une α-application.


2
Et ignorant aussi, dans le cas des nombres à virgule flottante, les questions d’arrondissement
Patrons de programmation en PRuby 271

Figure 5.11: Graphes de dépendances pour une réduction β vs. une réduction β-
logarithmique.

Alors que la première façon de parenthéser l’expression conduit à une évalua-


tion en temps linéaire (O(n)) — et ce même avec une évaluation parallèle —, la
deuxième façon permet une évaluation en temps logarithmique (O(lg n)) — d’où le
terme parfois utilisé de β-réduction logarithmique. Voir aussi la Figure 5.11 (adaptée
de [GUD96, p. 107]) — dans cette figure, f est l’opération binaire, plutôt que ⊕.
Comme on le verra dans les exemples qui suivent, la bibliothèque PRuby définit
des versions parallèles de ces méthodes, soit pmap et preduce, qui s’appliquent
toutefois uniquement à des objets de classe Array ou Range et non à des collections
arbitraires.
La Figure 5.12 présente la description de la méthode pmap telle qu’on la retrouve
dans la documentation en ligne de la bibliothèque PRuby.
Patrons de programmation en PRuby 272

Figure 5.12: Documentation de la méthode pmap.


Patrons de programmation en PRuby 273

5.4.4 Somme de deux tableaux

Programme Ruby 5.18 Fonction parallèle pour effectuer la somme de deux


tableaux avec pmap.
def somme_tableaux ( a , b , nb_threads = PRuby . nb_threads )
(0... a . size ). pmap ( nb_threads : nb_threads ) do | k |
a[k] + b[k]
end
end

Le Programme Ruby 5.18 présente une version parallèle d’une fonction pour ef-
fectuer la somme de deux tableaux à l’aide de pmap, et ce à l’aide de parallélisme à
granularité grossière, où les éléments à additionner sont répartis par blocs d’éléments
adjacents entre les nbt threads— par défaut, le mode de répartition est «static:
true». Difficile de faire plus simple!
Patrons de programmation en PRuby 274

5.4.5 Calcul de π à l’aide d’une méthode Monte Carlo

Programme Ruby 5.19 Fonction parallèle pour estimer la valeur de π à l’aide


d’une méthode Monte Carlo avec pmap.
def evaluer_pi ( nb_lancers , nbt = PRuby . nb_threads )
nb_dans_cercle = (0... nbt ). pmap ( nb_threads : nbt ) do
nb_dans_cercle_seq ( nb_lancers / nbt )
end

nb_total = nb_dans_cercle . reduce ( &:+ )

4.0 * nb_total / nb_lancers


end

Programme Ruby 5.20 Fonction parallèle pour estimer la valeur de π à l’aide


d’une méthode Monte Carlo avec preduce.
def evaluer_pi ( nb_lancers , nbt = PRuby . nb_threads )
total = (0... nbt )
. preduce (0 , nb_threads : nbt ) do | nb , _numt |
nb + nb_dans_cercle_seq ( nb_lancers / nbt )
end

4.0 * total / nb_lancers


end

Dans le Programme Ruby 5.19, qui utilise un pmap, on constate qu’après le pmap, il
est nécessaire de faire la sommation des résultats produits par chacun des threads. Il
s’agit donc d’un cas typique de réduction. Le Programme Ruby 5.20 présente donc
une nouvelle, et dernière, version parallèle de la fonction evaluer_pi, cette fois en
utilisant preduce. Là aussi, difficile de faire plus simple et plus succinct!
Patrons de programmation en PRuby 275

5.4.6 Factoriel

Programme Ruby 5.21 Fonction parallèle pour calculer fact(n) avec preduce.
def fact ( n , nbt )
(1.. n ). preduce ( 1 , nb_threads : nbt ) do | prod , k |
prod * k
end
end

Et quand on parle de solution parallèle simple — à granularité grossière: voir le


Programme Ruby 5.21 pour le calcul de fact(n)!
Patrons de programmation en PRuby 276

Soit la classe Ensemble traitée dans le labo #1. dont voici une version possible :
class Ensemble
def initialize ( * elements )
@elements = []
elements . each do | x |
@elements << x unless @elements . include ? x
end
end

def max
fail " L ’ ensemble est vide " if cardinalite == 0

m = @elements [0]
@elements . each do | x |
m = [m , x ]. max
end
m
end
...
end
On veut une version parallèle de cette méthode — pmax.

1. Peut-on simplement remplacer each par peach pour obtenir une version avec
parallélisme de boucles?

2. Si on veut utiliser du parallélisme de données, à quoi ressemblerait le code


de pmax?

Exercice 5.10: Méthode pmax pour la classe Ensemble.


Patrons de programmation en PRuby 277

Donnez une mise en oeuvre d’une méthode pselect qui est version parallèle de
select.

>> a = [*1..10]
=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

>> a.pselect { |x| x.even? }


=> [2, 4, 6, 8, 10]

>> a.pselect { |x| x > 9 }


=> [10]

>> a.pselect { |x| x < 0 }


=> []

Votre mise en oeuvre doit être aussi parallèle que possible. . . bien qu’une partie
puisse être faite de façon séquentielle — difficile de faire autrement /

Hypothèse/indice :

• On suppose que c’est l’évaluation du prédicat sur un élément qui est


coûteuse (longue à exécuter).

• Faites le travail en deux passes : une première parallèle, l’autre séquentielle.

• La méthode compact supprime les nil :


[10, 20, nil, 99, nil].compact == [10, 20, 99]
Exercice 5.11: Méthode pselect sur une collection de type Array.

5.4.7 Forme générale de preduce


Les Figures 5.13–5.14 présentent la description génerale de la méthode preduce
telle qu’on la retrouve dans la documentation en ligne de la bibliothèque PRuby. On
remarque qu’il est possible de spécifier une méthode finale de réduction, exprimée
sous forme d’une lambda, utilisée tel qu’indiqué dans la figure suivante :
Patrons de programmation en PRuby 278

Figure 5.13: Documentation de la méthode preduce.


Patrons de programmation en PRuby 279

Figure 5.14: Documentation de la méthode preduce (suite).


Patrons de programmation en PRuby 280
Patrons de programmation en PRuby 281

5.5 Parallélisme style «Coordonnateur/Travailleurs» :


peach/pmap dynamique et TaskBag
Jusqu’à présent, nous avons vu deux grandes façons de créer des threads et de leur
attribuer des tâches à traiter :

• Création dynamique de threads : Dans cette approche, on crée des threads


de façon dynamique — e.g., avec pcall ou avec des future — et chaque
thread traite une tâche qui lui est associée au moment où la tâche est créée.
Un exemple type est celui du parallélisme récursif, illustré notamment par le
Programme Ruby 5.5 (p. 241).
Un désavantage de cette approche est que le nombre de threads créés varie
généralement en fonction de la taille du problème, et donc ce nombre de threads
peut être (très!?) élevé. Et c’est le cas même si, comme dans la solution à
l’exercice 5.3, on limite le nombre futures utilisés, et donc le nombre de
threads créés.

• Répartition statique du travail entre un nombre fixe de threads : Dans


cette approche, on crée un nombre fixe de threads et on répartit le travail à
faire entre les threads de façon statique. Des exemples de ce type sont les
Programmes Ruby 5.8 et 5.18, qui utilisent respectivement du parallélisme
de boucle (peach) et du parallélisme de données (pmap), et où les éléments
à traiter peuvent être répartis par blocs (d’éléments adjacents) ou de façon
cyclique.

Créer des threads de façon dynamique peut être couteux, surtout si le nombre
de threads est élevé. De plus, comme on l’a vu avec l’exemple du calcul récursif et
parallèle de la fonction pour factoriel, il arrive souvent que le seul travail effectué
par un thread . . . soit de créer d’autres threads et d’attendre qu’ils terminent pour
combiner les solutions produites par les appels récursifs.
Les coûts élevés de création des threads peuvent être réduits lorsqu’on crée, une
seule fois, un nombre fixe de threads. Cette approche est particulièrement efficace
lorsque le nombre de threads est du même ordre de grandeur que le nombre de
processeurs.
Malheureusement, une répartition statique entre un nombre fixe de threads ne
fonctionne bien que si le travail à effectuer par chacun des threads prend sensible-
ment le même temps.
Patrons de programmation en PRuby 282

Supposons une répartition statique des tâches entre quatre (4) threads où chaque
thread est exécuté par un processeur indépendant et où le temps requis pour que
chaque thread traite son groupe de tâches est comme suit — l’unité de mesures n’a
pas d’importance :

• Thread 0 : 10

• Thread 1 : 40

• Thread 2 : 20

• Thread 3 : 20

1. Quel sera le temps total d’exécution?

2. Quelle sera l’accélération?

3. Quelle pourrait être la meilleure accélération si on réussisait à bien répartir


le travail?

Exercice 5.12: Temps d’exécution et accélération pour des tâches de temps vari-
able.

Ce qu’il faudrait est une approche qui permet d’utiliser un nombre fixe et
limité de threads, créés en une seule fois, tout en permettant une répartition
plus égale du travail à faire entre les threads. Une telle approche est possible avec
une répartition dynamique des tâches ainsi qu’avec l’utilisation d’une approche
de type «Coordonnateur/Travailleurs».
Patrons de programmation en PRuby 283

5.5.1 Traitement d’une série de fichier avec wc et pmap


Sur Unix/Linux, l’utilitaire wc permet de calculer le nombre de lignes, de mots et
de caractères contenus dans un fichier texte. Voici un exemple :
$ cat foo.txt
1
22 22
333 333 333
4444 4444 4444 4444

$ wc foo.txt
4 10 40 foo.txt

Le fichier foo.txt contient donc 5 lignes, 10 mots et 40 caractères — chaque


ligne se termine par un saut de ligne, donc un caractère «\n», et ces sauts de ligne
sont comptés parmi les caractères.

Programme Ruby 5.22 Version parallèle de la fonction wc appliquée à une liste


de fichiers avec pmap.
# Compte le nombre de mots dans une ligne .
def nb_mots ( ligne )
ligne . strip . split (/\ s +/). size
end

# Version Ruby de wc qui traite un (1) fichier .


def wc1 ( fich )
lignes = IO . readlines ( fich )

nb_lignes = lignes . size


nb_mots = lignes . map { | l | nb_mots ( l ) }. reduce (0 , &:+)
nb_cars = lignes . map (&: size ). reduce (0 , &:+)

[ nb_lignes , nb_mots , nb_cars , fich ]


end

# Fonction qui applique wc sur une liste de fichiers .


def wc ( fichs )
fichs . pmap { | fich | wc1 ( fich ) }
end
Patrons de programmation en PRuby 284

Le problème qu’on veut traiter est le suivant : étant donné une liste de fichiers
(Array), on veut obtenir une liste de résultats équivalents à ceux produit par wc.
Le Programme Ruby 5.22 présente une première solution, mise en oeuvre avec
pmap :

• La méthode strip permet de supprimer les blancs au début et à la fin d’une


chaîne de caractères. Par exemple : ’ abc def ’.strip == ’abc def’
• Étant donnée une chaîne, la méthode split retourne un tableau des sous-
chaînes, et ce en utilisant l’expression en argument comme délimiteur de sé-
paration. Par exemple :
>> ’ bcaadefxyzaa ’. split (/ a +/)
= > [ ’ bc ’ , ’ defxyz ’]

>> ’ abc def xxx y ’. split (/\ s +/)


= > [ ’ abc ’ , ’ def ’ , ’ xxx ’ , ’y ’]

• La méthode IO.readlines(fich) retourne un tableau contenant toutes les


lignes du fichier fich.

• Le résultat retourné par wc1 est un tableau de quatre éléments, équivalent à


ce qui est retourné par wc. On notera qu’en Ruby, les tableaux n’ont pas à
être homogènes, i.e., les éléments d’un Array peuvent être de types divers.

Pour que le traitement se fasse de façon parallèle, un pmap est utilsé sur la liste
des fichiers. Aucun argument n’étant spécifié, on utilisera donc PRuby.nb_threads
threads — i.e., un nombre fixe de threads — et une répartition statique par bloc —
pmap = pmap() = pmap( static: true, nb_threads: PRuby.nb_threads ).

Quel est le principal défaut du Programme Ruby 5.22?


Exercice 5.13: Le défaut de la solution statique pour wc /

Une solution simple au problème identifié dans l’exercice 5.13 consiste à définir
wc comme suit :
def wc ( fichs )
fichs . pmap ( dynamic : true ) do | fich |
wc1 ( fich )
end
end
Patrons de programmation en PRuby 285

Un appel à pmap qui utilise «dynamic: true» est équivalent à un appel avec
«dynamic: 1». Comme dans le cas statique, on va créer un nombre fixe de threads
— égal à PRuby.nb_threads si le nombre de threads n’est pas spécifié explicitement.
La différence par rapport à static est que ces threads vont se répartir les éléments
à traiter non pas au moment du lancement des threads, mais en cours d’exécution,
au fur et à mesure où les éléments de la collection auront été traités.
Donc, lorsqu’un thread est activé, sa première action est d’obtenir le prochain
élément à traiter parmi ceux qui ne sont pas encore traités. Lorsque le thread
termine de traiter cet élément, il tente alors d’obtenir le prochain élément qui n’a
pas encore été traité. Finalement, l’exécution du thread se termine lorsqu’il n’y
a plus aucun élément à traiter.
Cette façon de procéder permet de mieux répartir la charge de travail entre les
threads. Ainsi, il est possible que pendant qu’un thread traite un très gros fichier
— donc très long à parcourir — un autre thread traite plusieurs petits fichiers. Au
final, les chances que tous les threads finissent en même temps, ou presque, sont
donc améliorées.
Il est aussi possible d’augmenter la granularité des tâches simplement en spéci-
fiant comme valeur un entier supérieur à 1. Dans notre exemple, si on avait utilisé
l’appel «fichs.pmap( dynamic: 5 ) {. . . }», alors un thread aurait obtenu les noms
de fichiers à traiter par blocs de 5 — ou moins pour le dernier bloc lorsque le nombre
total d’éléments n’est pas divisible par 5.

5.5.2 Approche Coordonnateur–Travailleurs et sac de tâches


Lorsqu’on utilise une approche avec répartition dynamique des tâches, on parle sou-
vent d’une approche Coordonnateur–Travailleurs : un des threads joue le rôle
de coordonnateur,dont le travail consiste à gérer les tâches et à les répartir entre
les autres threads lesquels font le «vrai travail» — d’où le terme de travailleurs.
Comme on utilise un nombre fixe de travailleurs, on crée donc un nombre fixe de
threads.
Dans certains problèmes, l’exécution d’une tâche peut entrainer la création
d’une ou plusieurs nouvelles tâches. On utilise alors une structure de données
particulière pour représenter les tâches en attente d’exécution, qu’on appelle le sac
de tâches.
C’est le coordonnateur — parfois un thread explicite, parfois simplement le
thread -maître — qui gère les accès au sac de tâches, pour obtenir une tâche ou
en ajouter une nouvelle.
Le code exécuté par les travailleurs a typiquement l’allure suivante :

THREAD travailleur( sac_tâches )


Patrons de programmation en PRuby 286

terminé ← false
WHILE !terminé DO
obtenir une tâche du sac_tâches # Bloquant!
IF il restait une tâche à exécuter THEN
exécuter la tâche obtenue... possiblement
en générant de nouvelles tâches
ELSE
terminé ← true
END
END
END

5.5.3 Traitement d’une série de fichiers avec wc et un TaskBag

Programme Ruby 5.23 Version parallèle de la fonction wc appliquée à une liste


de fichiers avec un TaskBag.
def wc ( fichiers , taille_tache = 2 )
nb_travailleurs = PRuby . nb_threads

# Les taches a mettre initialement dans le sac .


taches = (0... fichiers . size )
. step ( taille_tache )
. map { | i | i ..[ i + taille_tache - 1 , fichiers . size - 1]. min }

res = Array . new ( fichiers . size ) # Tableau des resultats .

# On active les travailleurs et on attend qu ’ ils terminent .


PRuby :: TaskBag . create_and_run ( nb_travailleurs , * taches ) do | sac_taches |
sac_taches . each do | i_j |
res [ i_j ] = i_j . map { | k | wc1 ( fichiers [ k ]) }
end
end

res
end

Le Programme Ruby 5.23 présente une nouvelle version de la fonction wc appliquée


à une liste de fichiers, mais réalisée cette fois avec un sac de tâches explicite — un
objet de classe TaskBag — et un groupe explicite de threads travailleurs :
Patrons de programmation en PRuby 287

• On utilise un thread par travailleur et autant de travailleurs que de processeurs


(PRuby.nb_thereads). Quant au coordonnateur, il est implicitement associé
au thread qui exécute la méthode wc, donc qui crée le sac et active les threads
travailleurs.

• La méthode de création d’un objet TaskBag — create_and_run — reçoit


en argument explicite le nombre de travailleurs qui seront lancés et qui se
partageront l’utilisation sac de tâches.
Elle reçoit aussi en argument explicite la liste des tâches qui seront mises
initialement dans le sac de tâches.
Ici, une tâche est représentée par un Range. Par exemple, si fichs.size = 10
et taille_tache = 3, alors les tâches suivantes seront ajoutées dans le sac :
0..2, 3..5, 6..8, 9..9.
La méthode reçoit aussi en argument (implicite) un bloc, qui représente le code
qui sera exécuté par chacun des threads. Ce bloc reçoit deux (2) arguments :
le premier argument, obligatoire, indique le sac de tâches créé par l’appel à
create_and_run et partagé par les threads ; le deuxième argument, optionnel,
identifie le numéro du thread.

• C’est la méthode each qui permet à chacun des threads d’obtenir une tâche
du sac. Un thread donné ne recevra pas toutes les tâches, uniquement un
sous-ensemble — en d’autres mots, les tâches vont se répartir entre les divers
threads par l’intermédiaire du each, mais pas nécessairement de façon uniforme
puisqu’il s’agit d’un comportement dynamique.

– Si un thread tente d’obtenir une tâche et que le sac n’est pas vide, alors
l’une des tâches est retirée du sac et retournée au travailleur — c’est un
sac, donc l’ordre de retrait des tâches est indéterminé.
– Si un thread tente d’obtenir une tâche et que le sac est présentement
vide, alors le comportement dépend de l’état des autres threads, d’où
l’importance de connaitre le nombre de threads qui se partagent le sac :
∗ Il y a encore des threads actifs, i.e., qui ne sont pas bloqués en attente
d’une tâche : dans ce cas, le thread est mis en attente. . . parce qu’un
des autres threads encore actifs pourrait ajouter une nouvelle tâche
dans le sac.
∗ Le thread est le dernier thread actif : dans ce cas, il est impossible
que d’autres tâches soient ajoutées au sac. Tous les threads bloqués
doivent alors être réactivés en signalant que le sac est définitivement
vide, qu’il n’y aura plus de tâches à traiter, et donc qu’ils peuvent —
Patrons de programmation en PRuby 288

en fait, doivent — terminer! Dans la méthode TaskBag#each, cet


état est signalé avec each qui retourne nil à chacun des threads en
attente.

• Notons que dans le code du travailleur — le bloc spécifié lors de l’appel à


create_and_run —, une tâche obtenue est un Range, affecté à la variable
i_j. Dans l’exemple, on voit que le résultat du map — un Array — est affecté
directement à la tranche appropriée du tableau de résultat :
res[i_j] = i_j.map { ... }

Cet exemple permet d’illustrer le fonctionnement d’un sac de tâches, mais son
utilisation n’est pas strictement nécessaire, puisqu’un simple pmap avec dynamic
aurait été suffisant. C’est le cas parce que toutes les tâches sont créées une fois pour
toute par le coordonnateur, au moment de la création du sac lorsque les travailleurs
sont lancés. En d’autres mots, l’exécution d’une tâche n’entraîne pas la création de
nouvelles tâches.

5.5.4 Fonction mystère

Programme Ruby 5.24 Fonction mystere.


def mystere ( n , seuil )
resultats = PRuby :: TaskBag . create_and_run ( PRuby . nb_threads , 1.. n ) do | sac
res = 1

sac_taches . each do | range |


i , j = range . begin , range . end
while j - i + 1 > seuil
m = (i + j) / 2
sac_taches . put ( m + 1).. j
j = m
end
res *= ( i .. j ). reduce (1 , :*)
end

res
end

resultats . reduce (1 , :*)


end
Patrons de programmation en PRuby 289

Le Programme Ruby 5.24 présente une fonction mystere qui utilise un TaskBag et
où l’ajout de nouvelle tâches se fait vraiment de façon dynamique — une nouvelle
tâche peut être ajoutée au sac (avec TaskBag#put) pendant la traitement d’une
tâche.
Plus précisément, au début de la fonction mystere, le coordonnateur ajoute
une seule et unique tâche dans le sac — la tâche 1..n. Par la suite, ces sont les
travailleurs qui ajoutent de nouvelles tâches de la forme (m+1)..j (dans le while).

Que fait la fonction mystere? Quelle stratégie de programmation est utilisée?


Exercice 5.14: Fonction mystere.

Soit la méthode suivante définie dans la classe Array :


class Array
def mystere
res = Array . new ( size )
PRuby . pcall 0... size ,
- >( k ) { res [ k ] = self [ size -k -1] }

res
end
end

1. Que fait cette méthode? Quel nom plus significatif peut-on lui donner?

2. Écrivez une version équivalente de cette méthode, mais qui utilise plutôt du
parallélisme de boucles — donc avec peach ou peach_index.

3. Même question, mais avec du parallélisme de données — plus spécifique-


ment avec pmap.

4. Même question, toujours avec du parallélisme de données, mais cette fois


avec preduce.
Note : Cette dernière méthode n’est pas triviale. . . et il faut utiliser
l’argument (optionnel) final_reduce de la méthode preduce.

Exercice 5.18: Méthode mystere de la classe Array


Patrons de programmation en PRuby 290

Soit un tableau a de 12 éléments, où la valeur en rouge indique le temps requis pour


traiter cet élément.

10 20 30 40 50 100 200 50 40 30 20 10

Supposons qu’on ne considère que les temps indiqués, donc en ignorant les autres
surcoûts d’exécution.

Pour chaque appel ci-bas, indiquez quelles tâches seront attribuées à chaque
thread et quel sera le temps total d’exécution.

Note : On suppose que les threads obtiennent les tâches dans l’ordre de priorité de leur
numéro — donc le premier thread obtient la première tâche, etc., puis par la suite si deux
threads veulent une tâche «en même temps», alors c’est le thread avec le plus petit numéro
qui obtient une tâche en priorité.

1. a.peach( static: true, nb_threads: 3 ) { ... }

2. a.peach( static: 1, nb_threads: 3 ) { ... }

3. a.peach( dynamic: true, nb_threads: 3 ) { ... }


Note : «dynamic: true» = «dynamic: 1»

Exercice 5.17: Modes de répartition des tâches.


Patrons de programmation en PRuby 291

5.6 Parallélisme de flux de données avec filtres et


pipelines : pipeline, pipeline_source et pipeline_sink
La prochaine structure de programmation parallèle de PRuby que nous allons in-
troduire permet le «parallélisme de flux », aussi appelée parallélisme avec «filtres et
pipelines».
Dans cette approche, le parallélisme vient de ce qu’on va traiter des flux de
données — des séries de données, des data streams — et que le traitement à ef-
fectuer sur une donnée d’un flux sera décomposé en une série d’étapes indépendantes,
comme dans une chaine de montage.
Un endroit où on trouve cette forme de parallélisme, même si on ne s’en rend pas
toujours compte, est au niveau matériel des ordinateurs. Comme on l’a vu précédem-
ment, les ordinateurs modernes exécutent les instructions d’un programme à l’aide
d’un pipeline d’instructions, une «chaîne de montage» pour l’exécution des instruc-
tions. Une instruction donnée va donc nécessiter plusieurs cycles pour s’exécuter,
puisqu’elle doit parcourir l’ensemble de la chaîne de montage. Par contre, à un
instant donné, on aura plusieurs instructions en cours d’exécution, chacune à un
étage distinct du pipeline, d’où le parallélisme — dans ce cas, on parle parfois de
«parallélisme temporel».
Plus spécifiquement, l’approche que nous allons présenter dans cette section
s’inspire à la fois des pipes Unix — quant à la façon de créer les pipelines avec
l’opérateur «|» — mais aussi du langage Go3 — quant à la syntaxe pour manipuler
les canaux de communication (lecture, écriture et fermeture).

5.6.1 Unix et ses pipes


Un contexte où les flux de données, filtres et pipelines sont fréquents — pour ne pas
dire omniprésents — est lorsqu’on programme sous Unix et qu’on utilise des pipes
— le fameux symbole «|». Voici ce qu’expriment certains auteurs sur la philosophie
qui sous-tend l’approche Unix :

(i) Make each program do one thing well. [. . . ]


(ii) Expect the output of every program to become the input to
another, as yet unknown, program. Don’t clutter output with extra-
neous information. Avoid stringently columnar or binary input formats.
Don’t insist on interactive input.
McIlroy et al. [MPT78] (cité dans [Ray04])

3
https://golang.org/
Patrons de programmation en PRuby 292

Unix tradition strongly encourages writing programs that read and write
simple, textual, stream-oriented, device-independent formats. Under clas-
sic Unix, as many programs as possible are written as simple filters,
which take a simple text stream on input and process it into another
simple text stream on output.

Despite popular mythology, this practice is favored not because Unix pro-
grammers hate graphical user interfaces. It’s because if you don’t write
programs that accept and emit simple text streams, it’s much more diffi-
cult to hook the programs together.
E. Raymond [Ray04]

cat $1.tex \
| sed ’/\\begin{figure}/,/\\end{figure}/d’ \
| sed ’/\\begin{table}/,/\\end{table}/d’ \
| grep -v "^%" \
| tr "[~]" "[ ]" \
| tr "[\t]" "[\n]"\
| tr "[ ]" "[\n]"\
| grep -v ’\\’ \
| wc -w

Figure 5.15: Script Unix pour supprimer des commandes LATEX dans un fichier et
compter le nombre de «vrais» mots d’un document.

La Figure 5.15 présente un exemple d’utilisation de processus et de pipes Unix.


Ce script sert à éliminer les commandes d’un fichier LATEX dans le but de compter le
nombre de «vrais» mots contenus dans le texte — donc sans compter les commandes
LATEX.4 Les diverses commandes Unix utilisées dans ce script sont les suivantes
(descriptions produites avec man) :

• sed : “The sed utility is a stream editor that reads one or more text files,
makes editing changes according to a script of editing commands, and writes
the results to standard output.
• grep : “The grep utility searches files for a pattern and prints all lines that
contain that pattern.”
4 A
LTEX est un langage et système pour composer des documents, fondé sur l’utilisation de TEX.
Les commandes LATEX sont indiquées par des identificateurs qui débutent par un «\», par exemple,
\section{...}, \title{...}, \begin{...}, etc.
Patrons de programmation en PRuby 293

Note : Rôle de l’option “-v” de grep : “Print all lines except those that contain
the pattern.
• tr : “The tr utility copies the standard input to the standard output with
substitution or deletion of selected characters.”.
• wc : “The wc utility reads one or more input files and, by default, writes the
number of newline characters, words and bytes contained in each input file to
the standard output.”.
Note : Rôle de l’option “-w” de wc : “Count words delimited by white space
characters or new line characters.”

La tâche à effectuer est réalisée en la décomposant en plusieurs sous-tâches plus


simples et en combinant simplement ces sous-tâches à l’aide de pipes :

• On filtre — au sens de «supprimer» — les blocs de lignes qui forments les


figures ou tables.
• On filtre les lignes ne contenant que des commentaires (débutant avec «%»).
• On transforme les espaces insécables («˜») en espaces blancs ordinaires.
• On transforme les caractères de tabulation et blancs en sauts de lignes — donc
on met un mot (groupe de caractères non blancs) par ligne.
• On filtre (supprime) toutes les lignes contenant une commande LATEX.
• On compte le nombre de mots résultants.

Dans ce script, on utilise donc une forme de décomposition «diviser-pour-régner»


— on décomposer un problème complexe en sous-problèmes plus simples, on résoud
les sous-problèmes, puis on combine les solutions aux sous-problèmes. La différence
avec les exemples vus précédemment pour le parallélisme récursif est que cette dé-
composition ne se fait pas de façon récursive!
Sous Unix, l’utilisation de pipes implique aussi que toutes ces commandes pour-
raient s’exécuter de façon concurrente et parallèle. Ainsi, pendant qu’une commande
traite un ligne présente sur son flux d’entrée, une autre commande pourrait être en
train de traiter une ligne, différente, de son propre flux d’entrée.
Patrons de programmation en PRuby 294

5.6.2 PRuby et ses Channels


En PRuby, le parallélisme de filtres et pipelines s’exprime à l’aide de filtres spécifiés
par des lambda-expressions, interconnectés par des canaux de communication de
classe Channel tel qu’illustré dans les figures ci-bas.

Un filtre est défini à l’aide d’une lambda-expression, laquelle doit recevoir deux
arguments. Ce sont ces arguments qui donnent accès aux deux canaux associés
au filtre — le canal d’entrée, typiquement dénoté par cin, et le canal de sortie,
typiquement dénoté par cout :

La figure 5.16 présente les principales méthodes associées à la classe Channel.


Soulignons que la fin d’un flux de données est signalée par la valeur PRuby::EOS.
Cette valeur est retournée de façon persistente, i.e., si un appel à get retourne
PRuby::EOS, alors tous les appels subséquents vont continuer à retourner PRuby::EOS.
Soulignons aussi que cette valeur est automatiquement transmise sur un canal de
sortie lorsque la méthode close est appelée sur un tel canal.
Patrons de programmation en PRuby 295

Figure 5.16: Les principales méthodes de la classe Channel : initialize, get, put,
each et close (1ère partie).
Patrons de programmation en PRuby 296

Figure 5.16: Les principales méthodes de la classe Channel : initialize, get, put,
each et close (2e partie).
Patrons de programmation en PRuby 297

5.6.3 Tri unique des mots d’un fichier


Le Programme Ruby 5.25 définit une fonction, avec filtres et pipelines, pour identifier
tous les mots d’un fichier texte et produire en sortie un fichier contenant ces mots,
mais avec un seul mot par ligne, en ordre alphabétique et sans doublon. Il
s’agit donc d’un programme pour réaliser une fonctionnalité semblable à celle de
«sort -u» sous Unix.
Patrons de programmation en PRuby 298

Programme Ruby 5.25 Fonction pour trier les mots d’un fichier, en s’assurant
que chaque mot apparaît au plus une fois.
def trier_mots_uniques ( fich_entree , fich_sortie )
generer_mots = lambda do | cin , cout |
cin . each do | ligne |
ligne . split ( /\ s +/ ). each { | mot | cout << mot }
end
cout . close
end

filtrer_mots_invalides = lambda do | cin , cout |


cin . each { | mot | cout << mot if /^\ w + $ / =~ mot }
cout . close
end

trier = lambda do | cin , cout |


# Channel definit each et inclut Enumerable !
cin . sort . each { | mot | cout << mot }
cout . close
end

supprimer_doublons = lambda do | cin , cout |


precedent = nil
cin . each do | mot |
cout << mot if mot != precedent
precedent = mot
end
cout . close
end

( PRuby . pipeline_source ( fich_entree ) |


generer_mots |
filtrer_mots_invalides |
trier |
supprimer_doublons |
PRuby . pipeline_sink ( fich_sortie ))
. run
end

Lorsqu’on veut utiliser un pipeline en Pruby, on doit tout d’abord créer un


objet Pipeline. La façon la plus simple pour ce faire est d’utiliser des lambda-
Patrons de programmation en PRuby 299

expressions connectées avec l’opérateur «|».


Généralement, pour communiquer avec l’environnement, il faut aussi utiliser les
constructeurs pipeline_source et pipeline_sink, qui permettent de créer l’étage
initial du pipeline — sans canal d’entrée — et l’étage final — sans canal de sortie.
Une fois l’objet Pipeline créé, on lance alors son exécution avec run. Le thread
qui appelle run sera alors bloqué jusqu’à ce que tous les étages du pipeline aient
terminé — donc jusqu’à ce que tous les canaux aient été fermés et que le signal de
fin de flux — PRuby::EOS (End Of Stream) — ait été transmis à travers tous les
étages du pipeline.5
Un filtre — un étage d’un pipeline PRuby, sauf une source ou un puits (sink ) —
est donc un processus représenté par une lambda expression, laquelle reçoit en ar-
gument deux objets de type Channel, donc deux flux de données par l’intermédiaire
desquels le processus peut communiquer avec son voisin en amont ou son voisin en
aval — voisin gauche ou voisin droite, si on visualise le pipeline de la gauche vers la
droite :
1. Le canal d’entrée, sur lequel le processus peut appeler la méthode get, qui lui
permet de recevoir une donnée du voisin gauche.
Signalons que lorsque la valeur retournée par get est PRuby::EOS, alors c’est
que le flux d’entrée est terminé. Ce n’est pas une erreur d’appeler à nouveau
get ; par contre, tous les appels subséquents retourneront PRuby::EOS.
Notons aussi qu’il est généralement possible d’éviter de tester explicitement
la fin de canal en utilisant plutôt la méthode each, qui permet d’itérer sur
chacun des éléments dans le style propre à Ruby.

2. Le canal de sortie, sur lequel le processus peut appeler la méthode put, dans
le but de transférer un élément au voisin droite. Un synonyme pour cette
méthode est l’opérateur «<<».
Dans notre exemple, la tâche globale est décomposée en six (6) sous-tâches
plus simples, six processus qui pourront s’exécuter de façon concurrente et qui
s’échangeront des données et se synchroniseront par l’intermédiaire des canaux qu’ils
partagent :
• PRuby.pipeline_source : Cette méthode crée un processus, sans canal d’entrée,
qui reçoit en argument un nom de fichier (ou un Array) et qui émet sur son
canal de sortie les lignes — l’une après l’autre — puis qui ferme le canal de
sortie lorsque la fin du fichier est rencontrée, ce qui a pour effet d’émettre
PRuby::EOS sur ce canal de sortie.
5
Il est aussi possible de ne pas attendre/bloquer. Pour ce faire, il suffit d’appeler run avec
l’argument :NO_WAIT.
Patrons de programmation en PRuby 300

• generer_mots : Reçoit sur son canal d’entrée des lignes de texte et émet sur
son canal de sortie les mots – les suites de caractères sans espace blanc —
contenus sur chacune des lignes.

• filtrer_mots_invalides : Reçoit sur son canal d’entrée des suites de car-


actères non blancs, et n’émet sur le canal de sortie que ceux qui contiennent
uniquement des lettres.

• trier : Reçoit sur son canal d’entrée une suite de mots et émet sur le canal
de sortie ces même mots, mais triés.

• supprimer_doublons : Reçoit sur son canal d’entrée une suite de mots triés
et n’émet sur le canal de sortie que la première occurrence d’un mot lorsqu’un
mot apparaît plusieurs fois de suite (mots consécutifs, puisque triés).

• PRuby.pipeline_sink : Crée un processus qui reçoit sur son canal d’entrée


une suite d’éléments et les écrit dans un fichier dans l’ordre reçu (ou les ajoute
dans un Array).

Figure 5.17: Graphe des processus et de leurs canaux pour les filtres et le pipeline
du Programme Ruby 5.25.

La Figure 5.17 illustre la structure du pipeline pour le Programme Ruby 5.25 :


le premier processus n’a pas de canal d’entrée — c’est une source — alors que le
dernier n’a pas de canal de sortie — c’est un puits.
Un exemple pour mieux expliquer le rôle, et le contenu, des divers canaux de la
Figure 5.17 est le suivant.
Supposons que le fichier d’entrée contienne les lignes suivantes :

abc def
abc xx ! def
xx
Patrons de programmation en PRuby 301

Les éléments qui transiteront dans chacun des canaux seront alors les suivants —
indiqués sous forme d’une liste, en ignorant les caractères de saut de ligne, l’élément
à la position 0 indiquant le premier élément à transiter sur le canal, etc — on ignore
les "\n" :
c0: [’abc def’, ’abc xx ! def’, ’xx’, EOS]
c1: [’abc’, ’def’, ’abc’, ’xx’, ’!’, ’def’, ’xx’, EOS]
c2: [’abc’, ’def’, ’abc’, ’xx’, ’def’, ’xx’, EOS]
c3: [’abc’, ’abc’, ’def’, ’def’, ’xx’, ’xx’, EOS]
c4: [’abc’, ’def’, ’xx’, EOS]

1. Dans le pipeline de la Figure 5.17, est-ce que les processus generer_mots et


filtrer_mots_invalides pourraient s’exécuter en parallèle?

2. Dans le pipeline de la Figure 5.17, est-ce que les processus


filtrer_mots_invalides et supprimer_doublons pourraient s’exécuter en
parallèle?

3. Quel est le degré maximum de parallélisme de ce pipeline?

Exercice 5.20: Exécution parallèle, ou non, d’un pipeline.


Patrons de programmation en PRuby 302

5.6.4 Problème «de Jackson»


Dans l’exemple qui précède, l’utilisation de filtres et pipelines permet de bien modu-
lariser la solution, et ce en utilisant une approche diviser-pour-régner non récursive,
où chaque sous-tâche est clairement identifiée.
Certains problèmes, bien qu’ils semblent simples à première vue, peuvent être
difficiles à résoudre à l’aide d’un programme séquentiel, comme l’a montré M. Jack-
son il y a longtemps [Jac75, Jac83].
Voici un exemple simple d’un tel problème, que nous appelerons le problème «de
Jackson», qui a cette propriéte. Le problème est de transformer le contenu d’un
fichier texte comme suit :

• On reçoit en entrée un fichier texte formé de lignes de caractères de longueur


possiblement variable.

• On veut produire en sortie un fichier texte, avec les mêmes caractères, y com-
pris les blancs, mais où toutes les lignes sont exactement de longueur n — sauf
peut-être la dernière ligne.

• La seule différence au niveau des caractères émis est que lorsqu’on rencontre
deux caractères «*» consécutifs dans le flux d’entrée, alors on doit les rem-
placer par le caractère unique «^».

La Figure 5.18 présente un exemple de fichier d’entrée et du fichier de sortie


résultat, et ce pour n=4.
Patrons de programmation en PRuby 303

Entree:
-------
abc ** dsds cssa
ssdsx
fssfdfdfdfdfdf
s.s.**xtx*zy

Sortie:
-------
abc
^ ds
ds c
ssas
sdsx
fssf
dfdf
dfdf
dfs.
s.^x
tx*z
y

Figure 5.18: Exemple d’un fichier d’entrée et du fichier de sortie correspondant pour
le problème de Jackson avec n=4.
Patrons de programmation en PRuby 304

Programme Ruby 5.26 Fonction pour le problème de Jackson, avec filtres et


pipeline PRuby.
def transformer_jackson ( fich_donnees , fich_sortie , n )
# Transforme flux de lignes en flux de caracteres .
depaqueter = lambda do | cin , cout |
cin . each do | ligne |
ligne . each_char { | c | cout << c }
end
cout . close
end

changer_exposant = lambda do | cin , cout |


cin . each do | c |
( cin . get ; c = " ^ " ) if c == " * " && cin . peek == " * "
cout << c
end
cout . close
end

# Transforme flux de cars en flux de lignes de longueur n .


paqueter = lambda do | cin , cout |
ligne = " "
cin . each do | char |
ligne << char
( cout << ligne ; ligne = " " ) if ligne . size == n
end

cout << ligne unless ligne . empty ?


cout . close
end

( PRuby . pipeline_source ( fich_donnees ) |


depaqueter |
changer_exposant |
paqueter |
PRuby . pipeline_sink ( fich_sortie )).
run
end

Le Programme PRuby 5.26 présente une solution avec filtres et pipeline, où la


tâche essentielle (donc, on ignore pipeline_source et pipeline_sink) est décom-
Patrons de programmation en PRuby 305

posée entre trois (3) sous-tâches :


• depaqueter : Ce processus transforme simplement un flux de lignes en un
flux de caractères. Le canal d’entrée contient donc des lignes de longueur
arbitraire, alors que le canal de sortie contient une suite de caractères.
• changer_exposant : Ce processus examine les caractères et remplace les dou-
blons «**» par l’unique caractère «^». Signalons que le code de cette lambda-
expression est relativement simple car il utilise chan.peek. Cette méthode
permet de voir le prochain élément du canal d’entrée, mais sans le retirer du
canal. Donc, si on fait plusieurs appels consécutifs à peek sans faire de get,
on obtiendra toujours le même caractère — ce qui n’est évidemment pas le cas
pour get, qui «consomme» le caractère!
• paqueter : Ce processus reçoit un flux de caractères et les accumule dans un
tampon (ligne) jusqu’à ce que le tampon contienne exactement n éléments,
et alors il émet ces lignes sur le canal de sortie. Lorsque la fin du flux est
rencontrée, la ligne possiblement incomplète en cours de construction est aussi
émise avant de fermer le canal de sortie (et donc avant de propager le signal
PRuby::EOS).

Figure 5.19: Graphe des processus et de leurs canaux pour les filtres et le pipeline
du Programme Ruby 5.26.

La Figure 5.19 illustre la structure du pipeline pour le Programme Ruby 5.26 et


indique aussi le contenu des canaux — plus précisément, de combien de caractères
est formé chacun des éléments émis/reçus sur chacun des canaux.

(À faire si vous avez beaucoup (beaucoup !) de temps ,)


Écrivez une version séquentielle, en Java, de la méthode transformer_jackson.
Exercice 5.15: Version séquentielle de la transformation de Jackson.
Patrons de programmation en PRuby 306

Qu’est-ce qui sera imprimé par le programme suivant?


foo = lambda do | cin , cout |
cin . each do | x |
cout << x if x % 2 == 0
end
cout . close
end

bar = lambda do | cin , cout |


while ( x = cin . get ) != PRuby :: EOS
y = cin . get
cout << y if y != PRuby :: EOS
cout << x
end
cout << x
cout . close
end

baz = lambda do | cin , cout |


cin . each do | x |
cout << x
cout << x
end
cout . close
end

res = []

( PRuby . pipeline_source ([*0..8]) |


foo |
bar |
baz |
PRuby . pipeline_sink ( res ))

puts res . inspect

Exercice 5.16: Programme mystere avec pipeline.


Patrons de programmation en PRuby 307

5.6.5 Pipeline avec création explicite des canaux et des pro-


cessus dans le style du langage Go

Programme Ruby 5.27 Un petit pipeline avec trois processus.


# Les trois processus , qui seront organises en un pipeline lineaire:
# ... | p1 | p2 | p3 | ...

p1 = lambda do | cin , cout |


n = cin . get
(1.. n ). each { | i | cout << i }
cout . close
end

p2 = lambda do | cin , cout |


cin . each { | v | cout << 10 * v }
cout . close
end

p3 = lambda do | cin , cout |


r = 0
cin . each { | v | r += v }
cout << r
cout . close
end

# Creation des canaux.


c1 , c2 , c3 , c4 = Array . new (4) { PRuby :: Channel . new }

# Activation des processus.


p1 . go ( c1 , c2 )
p2 . go ( c2 , c3 )
p3 . go ( c3 , c4 )

# Ecriture initiale dans le premier canal => amorce le flux des donnees.
c1 << 10

# Reception du resultat du dernier canal.


puts c4 . get # => 550

Le Programme Ruby 5.27 illustre la création d’un petit pipeline (linéaire) com-
portant trois processus et quatre canaux (c1, c2, c3 et c4). Tant les canaux que
les processus sont créés explicitement, dans un style semblable à ce qu’on écrirait
dans le cadre du langage Go.6
6
https://golang.org/
Patrons de programmation en PRuby 308

• La méthode PRuby::Channel.new crée un nouveau canal. Par défaut, ce canal


est non borné — i.e., il n’y a aucune limite sur le nombre d’éléments pouvant
être ajoutés dans le canal (= il n’est jamais plein!).

• La méthode go, appliquée à une lambda-expression, lance un nouveau thread


pour évaluer cette lambda-expression, laquelle reçoit en argument les canaux
de communication à utiliser indiqués comme arguments à la méthode go.
De façon typique, dans un pipeline linéaire, on transmet deux canaux pour un
processus filtre — canal d’entrée + canal de sortie — et un seul canal pour
une source (canal de sortie) ou un puits (canal d’entrée).7
Par contre, pour une topologie plus complexe — e.g., arbre, graphe, cyclique
ou non —, un processus créé avec go pourrait utiliser plus de deux canaux.

• Le pipeline construit dans l’exemple est semblable à celui qui serait obtenu
avec l’utilisation de l’opérateur «|» : ...| p1 | p2 | p3 | ...
Lorsqu’on manipule des pipelines dans le style Unix, la création des canaux
et leur association aux processus est implicite — les canaux sont créés et
liés aux processus par la bibliothèque PRuby, lors de l’appel à la méthode
«|». Par contre, dans le style Go, on doit créer explicitement les canaux et les
transmettre explicitement aux processus (via la méthode go) pour les lier.

7
Notons que, dans notre exemple, il n’y a pas de source ou puits explicite, et ce pour simplifier
la présentation du code.
Patrons de programmation en PRuby 309

5.6.6 Parallélisme de flux avec streams


Une autre approche de parallélisme de flux est celle avec streams, introduite il y
a longtemps dans certains langages fonctionnels [AS85, MS+ 85], puis reprise plus
récemment par d’autres langages, notamment Java 8.0 avec les streams du paquetage
java.util.stream8 ou certains langages de traitement de données massives et leurs
collections parallèles [KKWZ15].

Programme Ruby 5.28 Fonction pour trier les mots d’un fichier, en s’assurant
que chaque mot apparaît au plus une fois — version avec streams.
def trier_mots_uniques ( fich_entree , fich_sortie )
PRuby :: Stream . source ( fich_entree )
. flat_map { | ligne | ligne . split ( /\ s +/ ) }
. filter { | mot | /^\ w + $ / =~ mot }
. sort
. uniq
. sink ( fich_sortie )
end

Le Programme Ruby 5.28 présente une fonction pour trier les mots d’un fichier,
en s’assurant que chaque mot apparaît au plus une fois — donc une fonction avec
un effet identique à celui de la fonction du Programme Ruby 5.25 (p. 298).
Ici, cette fonction utilise les streams de la bibliothèque PRuby pour obtenir l’effet
désiré, et ce en utilisant aussi du parallélisme de flux.
Quelques explications :

• Un stream est une forme de collection sur laquelle plusieurs des méthodes
associées aux collections Ruby peuvent être utilisées — donc, plusieurs des
méthodes du module Enumerable. Certaines de ces méthodes produisent un
résultat qui est lui-même un stream — e.g., map, filter, reject, etc. — alors
que d’autres produisent un résultat d’un autre type — e.g., reduce.

• Une caractéristique importante d’un stream est qu’il s’agit typiquement d’une
collection potentiellement infinie — donc non bornée (unbounded )
— produite de façon incrémentale. Des exemples de tels flux de don-
nées sont des données en temps réel, par exemple, informations de réseaux
de senseurs, paquets de traffic Internet, données financières (on-line financial
trading), fichiers de journalisation (log files), etc.
8
https://docs.oracle.com/javase/8/docs/api/java/util/stream/package-summary.
html
Patrons de programmation en PRuby 310

Exemple Ruby 5.2 Exemples pour illustrer les méthodes flat_map, filter et
uniq de la classe PRuby::Stream.
# Exemple flat_map
a = [1 , 0 , 2 , 0 , 3 , 4 , 0]
r = [1 , 1 , 2 , 2 , 3 , 3 , 4 , 4]

PRuby :: Stream . source ( a )


. flat_map { | x | x == 0 ? [] : [x , x ] }
. to_a
. must_equal r

# Exemple filter
a = [1 , 2 , 3 , 4]
r = [2 , 4]

PRuby :: Stream . source ( a )


. filter { | x | x . even ? }
. to_a
. must_equal r

# Exemple uniq .
a = [3 , 1 , 4 , 2 , 2 , 3 , 1]
r = [3 , 1 , 4 , 2]

PRuby :: Stream . source ( a )


. uniq
. to_a
. must_equal r
Patrons de programmation en PRuby 311

Pour plusieurs méthodes s’appliquant à un stream et produisant un stream en


résultat, la collection d’entrée n’a pas besoin d’être disponible dans son ensem-
ble pour produire le stream en sortie. Les éléments peuvent être traités au fur
et à mesure où ils deviennent disponibles, et les valeurs du stream résultant
sont donc produites et émises au fur et à mesure. Les méthodes s’exécutent
donc de façon incrémentale. En fait, lorsque le stream est effectivement non
borné, la collection dans son ensemble ne peut jamais être disponible.
• La méthode PRuby::Stream.source reçoit en argument un nom de fichier (ou
une collection ordinaire, par exemple, un Array) et génère un stream à partir
des lignes de ce fichier (ou des éléments de la collection).
• L’exemple Ruby 5.2 illustre trois des méthodes de la classe PRuby::Stream
utilisées dans le Programme Ruby 5.28, soit flat_map,9 filter et uniq. Ces
méthodes peuvent, tel qu’indiqué plus haut, être évaluées de façon incrémen-
tale sur les éléments du stream d’entrée.
Par contre, la méthode sort (non illustrée dans les exemples) ne possède pas
cette propriéte, puisque pour trier une série d’éléments, il est nécessaire de
les connaitre tous. La méthode sort ne peut donc être exécutée que si le
streamd’entrée est borné et elle ne commencera à émettre un résultat (le plus
petit élément) que lorsque la fin du stream aura été rencontrée.
• La méthode sink, comme le nom l’indique, est un puits, donc permet de
collecter les éléments du stream. Lorsque l’argument, comme ici, est un
String, la chaine est interprétée comme un nom de fichier et les éléments du
stream sont émis dans le fichier. Un stream peut aussi être transformé en une
collection «normale», par exemple, en un Array en utilisant la méthode to_a
(voir Exemple Ruby 5.2). Dans ce cas, là aussi le stream doit être borné.
• Dans la mise en oeuvre de la bibliothèque PRuby, une série de méthodes de ma-
nipulation de streams telle que celle présentée dans le Programme Ruby 5.28
est en fait une forme de pipeline avec filtres et canaux. Chaque méth-
ode qui génère ou traite un stream est un thread indépendant, qui com-
munique avec ses voisins par l’intermédiaire de canaux de communication
(PRuby::Channel). C’est le canal, non borné, qui contient les éléments du
stream. C’est ce qui explique qu’on a bien, dans Programme Ruby 5.28, du
parallélisme de flux comportant plusieurs threads actifs travaillant de façon
concurrente, ou parallèle.
9
Voici ce qu’indique la documentation de la méthode flat_map : «Applique une fonction
(un bloc) sur chacun des elements du stream d’entree. La fonction produit en sortie un Array
d’elements (contenant 0, 1 ou plusieurs elements), lesquels Arrays sont ensuite concatenes dans le
stream de sortie.»
Patrons de programmation en PRuby 312

5.A Sommaire : Comparaison de quelques approches


pour la somme de deux tableaux

Programme Ruby 5.29 Fonction parallèle itérative à granularité fine pour faire
la somme de deux tableaux avec pcall.
def somme_tableaux ( a , b )
c = Array . new ( a . size )

PRuby . pcall ( 0... c . size ,


- >( k ) { c [ k ] = a [ k ] + b [ k ] }
)

c
end

Question : Avantages? Désavantages?


Patrons de programmation en PRuby 313

Programme Ruby 5.30 Fonction parallèle itérative pour faire la somme de deux
tableaux avec pcall.
# Les indices pour la tranche du thread no . k .
def indices_tranche ( k , n , nb_threads )
( k * n / nb_threads )..(( k + 1) * n / nb_threads - 1)
end

# Somme sequentielle de la tranche pour indices ( inclusif )


def somme_seq ( a , b , c , indices )
indices . each { | k | c [ k ] = a [ k ] + b [ k ] }
end

def somme_tableaux ( a , b , nb_threads = PRuby . nb_threads )


c = Array . new ( a . size )

PRuby . pcall ( 0... nb_threads ,


lambda do | k |
somme_seq (
a, b, c,
indices_tranche (k , c . size , nb_threads )
)
end
)

c
end

Question : Avantages? Désavantages?


Patrons de programmation en PRuby 314

Programme Ruby 5.31 Fonction parallèle itérative pour faire la somme de deux
tableaux avec pcall.
def somme_seq_cyclique ( a , b , c , num_thread , nb_threads )
( num_thread ... a . size ). step ( nb_threads ). each do | k |
c[k] = a[k] + b[k]
end
end

def somme_tableaux ( a , b , nb_threads = PRuby . nb_threads )


c = Array . new ( a . size )

PRuby . pcall ( 0... nb_threads ,


lambda do | num_thread |
somme_seq_cyclique (
a, b, c,
num_thread , nb_threads
)
end
)

c
end

Question : Avantages? Désavantages?

Programme Ruby 5.32 Fonction parallèle itérative à granularité grossière pour


faire la somme de deux tableaux avec peach.
def somme_tableaux ( a , b , nb_threads = PRuby . nb_threads )
c = Array . new ( a . size )

(0... c . size ). peach ( nb_threads : nb_threads ) do | k |


c[k] = a[k] + b[k]
end

c
end

Question : Avantages? Désavantages?


Patrons de programmation en PRuby 315

Programme Ruby 5.33 Fonction parallèle pour effectuer la somme de deux


tableaux avec pmap.
def somme_tableaux ( a , b , nb_threads = PRuby . nb_threads )
(0... a . size ). pmap ( nb_threads : nb_threads ) do | k |
a[k] + b[k]
end
end

Question : Avantages? Désavantages?

Programme Ruby 5.34 Fonction parallèle pour effectuer la somme de deux


tableaux avec pmap.
def somme_tableaux ( a , b , nb_threads = PRuby . nb_threads )
(0... a . size ). pmap ( nb_threads : nb_threads , dynamic : true ) do | k |
a[k] + b[k]
end
end

Question : Avantages? Désavantages?


Partie IV

Métriques de performance et patrons


algorithmiques

316
Chapitre 6

Métriques de performance pour


algorithmes et programmes parallèles

317
Métriques de performance 318

6.1 Introduction : le temps d’exécution suffit-il?


Lorsqu’on désire analyser des algorithmes, on le fait de façon relativement abstraite,
en ignorant de nombreux détails de la machine (temps d’accès à la mémoire, vitesse
d’horloge de la machine, etc.).
Une approche abstraite semblable peut être utilisée pour les algorithmes paral-
lèles, mais on doit malgré tout tenir compte de nombreux autres facteurs. Par exem-
ple, il est parfois nécessaire de tenir compte des principales caractéristiques de la ma-
chine sous-jacente (modèle architectural) : s’agit-il d’une machine multi-processeurs
à mémoire partagée? Dans ce cas, on peut simplifier l’analyse en supposant, comme
dans une machine uni-processeur, que le temps d’accès à la mémoire est néglige-
able (bien qu’on doive tenir compte des interactions possibles entre processeurs par
l’intermédiaire de synchronisations). On peut aussi supposer, puisqu’on travaille
dans le monde idéal des algorithmes, qu’aucune limite n’est imposée au nombre de
processeurs (ressources illimitées, comme on le fait en ignorant, par exemple, qu’un
algorithme, une fois traduit dans un programme, ne sera pas nécessairement le seul
à être exécuté sur la machine). S’agit-il plutôt d’une machine multi-ordinateurs à
mémoire distribuée, donc où le temps d’exécution est très souvent dominé par les
coûts de communication entre processeurs? Dans ce cas, ce sont souvent les coûts
des communications qui vont dominer le temps d’exécution, ce dont il faut tenir
compte lorsqu’on analyse l’algorithme.
Un autre facteur important dont on doit tenir compte : le travail total effec-
tué. Ainsi, l’amélioration du temps d’exécution d’un algorithme parallèle par rap-
port à un algorithme séquentiel équivalent se fait évidemment en introduisant des
processus additionnels qui effectueront les diverses tâches de l’algorithme de façon
concurrente et parallèle. Supposons donc qu’on ait algorithme séquentiel dont le
temps d’exécution est O(n). Il peut être possible, à l’aide d’un algorithme paral-
lèle, de réduire le temps d’exécution pour obtenir un temps O(lg n). Toutefois, si
l’algorithme demande d’utiliser n processeurs pour exécuter les n processus, le coût
pour exécuter cet algorithme sera alors O(n lg n), donc asymptotiquement supérieur
au coût associé à l’algorithme séquentiel. Lorsque cela est possible, il est évidem-
ment préférable d’améliorer le temps d’exécution, mais sans augmenter le coût ou
le travail total effectué (deux notions que nous définirons plus en détail plus loin).
Dans ce qui suit :

• Métriques asymptotiques, pour des algorithmes


• Métriques réelles, pour des programmes

Dans les sections qui suivent, nous allons examiner diverses métriques perme-
ttant de caractériser les performances d’un algorithme, ou d’un programme, par-
allèle. La plupart de ces métriques seront , comme on l’a fait pour analyser les
Métriques de performance 319

algorithmes séquentiels, des métriques asymptotiques. Toutefois, nous présenterons


aussi certaines métriques qui peuvent être utilisées pour des analyses de programmes
concrets, avec un nombre limité (constant) de processeurs, donc des analyses non
asymptotiques.
Métriques de performance 320

6.2 Temps d’exécution et profondeur


Le temps d’exécution d’un algorithme parallèle (indépendant de sa mise en oeuvre
par un programme et de son exécution sur une machine donnée) peut être défini
comme dans le cas des algorithmes séquentiels, c’est-à-dire, en utilisant la nota-
tion asymptotique pour approximer le nombre total d’opérations effectuées (soit en
sélectionnant une opération barométrique, soit en utilisant diverses opérations sur
les approximations Θ pour estimer le nombre total d’opérations).
Une différence importante, toutefois, est que dans le cas d’une machine parallèle,
on doit tenir compte du fait que plusieurs opérations de base peuvent s’exécuter en
même temps. De plus, même en supposant des ressources infinies (nombre illimité de
processeurs), ce ne sont évidemment pas toutes les opérations qui peuvent s’exécuter
en même temps, puisqu’il faut évidemment respecter les dépendances de contrôle
et de données (les instructions d’une séquence d’instructions doivent s’exécuter les
unes après les autres ; un résultat ne peut être utilisé avant d’être produit ; etc.).
Lorsqu’on voudra analyser le temps d’exécution d’un algorithme écrit dans la no-
tation MPD, qui permet de spécifier facilement un grand nombre de processus, on va
supposer que chaque processus pourra, si nécessaire, s’exécuter sur son propre pro-
cesseur (processeur virtuel). En d’autres mots, en termes d’analyse d’algorithmes,
on supposera qu’on dispose d’autant de processeurs qu’on en a besoin pour exé-
cuter efficacement l’algorithme. Comme dans le cas d’un algorithme séquentiel,
bien qu’une telle analyse ne permette pas nécessairement de prédire de façon exacte
le temps d’exécution d’un programme réalisant cet algorithme sur une machine don-
née, elle est malgré tout utile pour déterminer comment le temps d’exécution croît
en fonction de la taille des données.

Définition 1 Le temps d’exécution TP (n) d’un algorithme parallèle A exécuté


pour un problème de taille n est le temps requis pour exécuter l’algorithme en sup-
posant qu’un nombre illimité de processeurs sont disponibles pour exécuter
les diverses opérations, c’est-à-dire en supposant qu’il y a suffisamment de pro-
cesseurs pour que toutes les opérations qui peuvent s’exécuter en parallèle le soient
effectivement.

Une telle approche est semblable à celle décrite par G. Blelloch [Ble96], qui parle
plutôt de la notion de profondeur (depth) du calcul associée à un algorithme.

Définition 2 La profondeur d’un calcul effectué par un algorithme est définie comme
la longueur de la plus longue chaîne de dépendances séquentielles présentes
dans ce calcul.

La profondeur (le temps) représente donc le meilleur temps d’exécution possible en


supposant une machine idéale avec un nombre illimité de processeurs. Le terme de
Métriques de performance 321

longueur du chemin critique (critical path length) est aussi parfois utilisé au lieu du
terme profondeur.

Nb. Temps
d’UE min.
1 9
2 6
3 6
4 6
... ...

Figure 6.1: Graphe de dépendances pour calcul d’une racine d’un polynome de
deuxième degré pour illustrer la notion du meilleur temps parallèle comme longueur
du plus long chemin.
Métriques de performance 322

Figure 6.2: Graphe de dépendances pour calcul de la somme d’un tableau de huit
éléments, pour illustrer la notion du meilleur temps parallèle comme longueur du
plus long chemin.
Métriques de performance 323

6.3 Coût, travail et optimalité


6.3.1 Coût
La notion de coût d’un algorithme a pour but de tenir compte à la fois du temps
d’exécution mais aussi du nombre (maximum) de processeurs utilisés pour obtenir
ce temps d’exécution. Ajouter des processeurs est toujours coûteux (achat, instal-
lation, entretien, etc.) ; pour un même temps d’exécution, un algorithme qui utilise
(asymptotiquement) moins de processeurs qu’un autre est donc préférable.

Définition 3 Soit un algorithme A utilisé pour résoudre, de façon parallèle, un


problème de taille n en temps TP (n). Soit p(n) le nombre maximum de processeurs
effectivement requis par l’algorithme. Le coût decet algorithme A est alors défini
comme C(n) = p(n) × TP (n)

Ici, on parle de coût d’un algorithme puisque si une machine qui exécute cet
algorithme doit, à un certain point de son fonctionnement, avoir jusqu’à p(n) pro-
cesseurs, alors la machine dans son ensemble avec les p(n) processeurs doit fonction-
ner durant TP (n) cycles, et ce même si certains des processeurs ne sont pas utilisés
durant tout l’algorithme.

6.3.2 Travail
Une autre caractéristique intéressante d’un algorithme parallèle est celle du travail
total effectué par l’algorithme.

Définition 4 Le travail, W (n), dénote le nombre total d’opérations effectuées par


l’algorithme parallèle pour un problème de taille n sur une machine à p(n) pro-
cesseurs.

Le travail effectué par un algorithme parallèle nous donne donc, d’une certaine
façon, le temps requis (plus précisément, un ordre de grandeur) pour faire simuler
l’exécution de l’algorithme parallèle par un programme à un seul processus, pro-
gramme qui simulerait l’exécution parallèle de plusieurs opérations en exécutant,
une après l’autre, les diverses opérations. On reviendra ultérieurement sur cette
notion de simulation.

6.3.3 Coût vs. travail


Telles que définies, les notions de travail et de coût sont très semblables. Si C(n) et
W (n) sont définis en utilisant les mêmes opérations barométriques, on aura néces-
sairement que TP (n) ≤ W (n) ≤ C(n). En d’autres mots, le travail est un estimé
Métriques de performance 324

plus précis du nombre total exact d’opérations exécutées qui tient compte du
fait que ce ne sont pas nécessairement tous les processeurs qui travaillent durant
toute la durée de l’algorithme.

Relation entre temps, travail et coût

• Séquentiel :

TS (n) = W (n) = C(n)

• Parallèle :

TP (n) ≤ W (n) ≤ C(n)


Métriques de performance 325

Figure 6.3: Le travail.


Métriques de performance 326

Figure 6.4: Le temps.


Métriques de performance 327

Figure 6.5: Le nombre de processeurs.


Métriques de performance 328

Figure 6.6: Le coût.


Métriques de performance 329

Soit le graphe suivant, qui représente les opérations et leurs dépendances pour faire
la somme d’un tableau v comptant n éléments :

1. Quel est le temps d’exécution?

2. Quel est le travail?

3. Quel est le nombre maximal de processeurs requis?

4. Quel est le coût?

Exercice 6.1: Temps vs. coût vs. travail.

Une autre différence importante entre le coût et le travail est la suivante :alors
que le travail est une métrique compositionnelle, ce n’est pas le cas pour le coût.
Plus précisément, soit S1 et S2 deux segments de code. Le travail effectué pour
la composition séquentielle de ces deux segments de code sera simplement la somme
du travail de chaque segment. En d’autres mots, on aura l’équivalence suivante :

Travail(S1 ; S2 ) = Travail(S1 ) + Travail(S2 )

Par contre, cette équivalence ne s’applique pas nécessairement pour le coût, puisque
les différentes étapes peuvent ne pas utiliser le même nombre de processeurset que
le coût ne s’intéresse qu’au nombre maximum de processeurs requis. Pour le travail,
Métriques de performance 330

on a donc la relation suivante :


Coût(S1 ; S2 ) ≥ Coût(S1 ) + Coût(S2 )

procedure foo( ref int a[*], int b[*], int n )


{
co [i = 1 to n]
a[i] = ... # Expression O(1)
oc

co [j = 1 to lg(n)]
proc( a, n )
oc
}

procedure proc( ref int a[*], int n )


{
for [i = 1 to n] {
a[i] = ... # Expression O(1)
}
}

Algorithme 6.1: Petit algorithme pour illustrer que le coût n’est pas une métrique
compositionnelle. Quel est le temps de cet algorithme? Quel est son travail? Quel
est son coût?

Par exemple, soit la procédure foo présentée dans l’algorithme 6.1.


Le temps total de cette procédure sera le suivant, puisque tous les appels à
«a[i] = ...» dans le premier co peuvent se faire en parallèle, de même qu’ensuite
tous les appels à proc dans le deuxième co : Θ(1) + Θ(n) = Θ(n). À cause du
premier co, le nombre maximum de processeurs requis par l’algorithme est n. Le
coût total est donc n × Θ(n) = Θ(n2 ) . . . et non pas Θ(n lg n) comme on pourrait
être tenté de le croire! En d’autres mots, pour le coût, on doit utiliser le nombre
maximum de processeurs requis par l’algorithme.
Par contre, si on calculait le travail, on aurait effectivement un résultat qui serait
Θ(n lg n) : n × Θ(1) + lg n × Θ(n) = Θ(n) + Θ(n lg n) = Θ(n lg n).

6.3.4 Optimalité
Soit un problème pour lequel on connaît un algorithme séquentiel optimal s’exécutant
en temps TS∗ (n) (pour un problème de taille n).
Métriques de performance 331

Note : Un algorithme séquentiel est optimal au sens où son temps d’exécution ne


peut pas être amélioré (asymptotiquement) par aucun autre algorithme séquentiel.
En d’autres mots, pour n’importe quel autre algorithme séquentiel s’exécutant en
temps TS (n), on aurait nécessairement que TS (n) ∈ Ω(TS∗ (n)).
On peut alors définir deux formes d’optimalité dans le cas d’un algorithme par-
allèle : une première forme faible et une autre plus forte.

Définition 5 Un algorithme parallèle est dit optimal en travail si le travail W (n)


effectué par l’algorithme est tel que W (n) ∈ Θ(TS∗ (n)).

En d’autres mots, le nombre total d’opérations effectuées par l’algorithme par-


allèle est asymptotiquement le même que celui de l’algorithme séquentiel, et ce
indépendamment du temps d’exécution TP (n) de l’algorithme parallèle.
Une définition semblable peut aussi être introduite en utilisant le coût plutôt
que le travail dans la définition précédente.

Définition 6 Un algorithme parallèle est dit optimal en coût si le coût C(n) de


l’algorithme est tel que C(n) ∈ Θ(TS∗ (n)).

Soulignons qu’un algorithme optimal, dans le sens décrit dans cette définition,
assure que l’accélération résultante (voir prochaine section) de l’algorithme optimal
sur une machine à p processeurs sera Θ(p), c’est-à-dire résultera en une accélération
linéaire.
Question : L’algorithme avec le graphe de l’exercice précédent est-il optimal en
travail? En coût?
Métriques de performance 332

6.4 Accélération et efficacité


6.4.1 Accélération relative vs. absolue
Lorsqu’on utilise un algorithme ou programme parallèle, on le fait dans le but
d’obtenir un résultat plus rapidement.1 Une première exigence pour qu’un algorith-
me/programmme parallèle soit intéressant est donc que son temps d’exécution soit
inférieur au temps d’exécution d’un bon algorithme/programme séquentiel équiva-
lent.
La notion d’accélération permet de déterminer de façon plus précise combien
fois un algorithme/programme parallèle est plus rapide qu’un algorithme/programme
séquentiel équivalent. On distingue deux façons de définir l’accélération : relative
ou absolue. Notons par TP (p, n) le temps d’exécution pour un problème de taille n
sur une machine à p processeurs — on a donc TP (n) = TP (+∞, n).
Notons TS∗ (n) le temps requis pour le meilleur algorithme/programme séquentiel
possible (pour un problème de taille n).

Définition 7 L’accélération relative Arp est définie par le rapport suivant :

TP (1, n)
Arp =
TP (p, n)

Pour l’accélération relative, on compare donc l’algorithme/le programme s’exécutant


sur une machine multi-processeurs avec p processeurs par rapport au même algorith-
me/programme s’exécutant sur la même machine mais avec un (1) seul processeur.
Cette mesure est souvent utilisée pour caractériser des algorithmes/programmes
parallèles. . . car elle est plutôt indulgente et optimiste. En d’autres mots, cette
mesure permet d’obtenir de bonnes accélérations, mais qui ne reflètent pas toujours
l’accélération réelle par rapport à ce qu’un bon algorithme/programme séquentiel
permet d’obtenir. Pour une comparaison plus juste, il est préférable d’utiliser la
notion d’accélération absolue.

Définition 8 L’accélération absolue Aap est définie par le rapport suivant :

TS∗ (n)
Aap =
TP (p, n)
1
Notons qu’on peut aussi vouloir utiliser des algorithmes et programmes parallèles dans le but
surtout de résoudre des problèmes de plus grande taille. Ceci est particulièrement le cas lorsque
qu’on utilise des machines parallèles à mémoire distribuée, dont l’espace mémoire est plus grand,
puisque composé de la mémoire des différentes machines.
Métriques de performance 333

En d’autres mots, pour l’accélération absolue, on compare l’algorithme/le pro-


gramme s’exécutant sur une machine multi-processeurs avec p processeurs avec le
meilleur algorithme/programme séquentiel permettant de résoudre le même prob-
lème.

Soit une machine avec p processeurs.

Quelle est en théorie la meilleure accélération absolue qu’il est possible d’obtenir?

TS∗ (n)
Aap = ≤ ?
TP (p, n)
Exercice 6.2: Meilleure accélération pour une machine avec p processeurs.

Accélération linéaire vs. superlinéaire


On parle d’accélération (relative ou absolue) linéaire lorsque Ap = p. En d’autres
mots, l’utilisation de p processeurs permet d’obtenir un algorithme/programme p
fois plus rapide. En général et, surtout, en pratique (avec des vraies machines et
de vrais programmes), de telles accélérations linéaires sont assez rares /
Bizarrement, toutefois, lorsqu’on travaille avec de vraies machines et de vrais
programmes (plutôt que simplement avec des algorithmes abstraits), on rencontre
parfois des accélérations superlinéaires, c’est-à-dire, telles que Ap > p. Deux
situations expliquent généralement ce genre d’accélérations superlinéaires.
Un premier cas est lorsque l’algorithme utilise une décomposition exploratoire [GGKK03,
Chap. 5], par exemple, une fouille dans un arbre de jeu : dans ce cas, le fait d’utiliser
plusieurs processus peut faire en sorte que l’un des processus “est chanceux ” et trouve
plus rapidement la solution désirée dans l’une des branches de l’arbre.
Métriques de performance 334

Une autre situation, mais qui s’applique clairement à l’exécution du programme


plutôt qu’à l’algorithme lui-même, est la présence d’effets de cache. Ainsi, toutes
les machines modernes utilisent une hiérarchie mémoire à plusieurs niveaux :

• Registres ;
2
• Cache(s) ;
• Mémoire (DRAM).

Les niveaux les plus près du processeur ont un temps d’accès plus rapide, mais
sont plus coûteux à mettre en oeuvre, donc sont de plus petite taille.
Il arrive parfois que l’exécution d’un programme sur une machine uni-processeur
nécessite un espace mémoire qui conduit à de nombreuses fautes de caches, par
ex., à cause de la grande taille des données à traiter. Or, lorsqu’on exécute le
même programme mais sur une machine multi-processeurs avec une mémoire cache
indépendante pour chaque processeur, le nombre total de fautes de caches est alors
réduit de façon importante (parce que l’ensemble des données peut maintenant entrer
dans l’ensembles des mémoires cache), conduisant à une accélération supérieure à
une accélération linéaire.

6.4.2 Efficacité de l’utilisation des processeurs


Alors que l’accélération nous indique dans quelle mesure l’utilisation de plusieurs
processeurs permet d’obtenir une solution plus rapidement, l’efficacité (en anglais :
efficiency) nous indique dans quelle mesure les divers processeurs sont bien
utilisés. . . ou pas.

Définition 9 L’ efficacité d’un algorithme (programme) est le rapport entre le temps


d’exécution séquentiel et le coût d’exécution sur une machine à p processeurs Ex-
primé autrement — et d’une façon plus facile à comprendre — l’efficacité est
obtenue en divisant par p l’accélération (absolue) pour p processeurs :

TS∗ (n) TS∗ (n) Aap (n)


Ep = = =
C(n) p × TP (p, n) p

Typiquement, on exprime l’efficacité en pourcentage. Donc, alors que pour


p processeurs, l’accélération idéale est de p, l’efficacité idéale est de 1 = 100 %! En
2
En fait, la plupart des machines modernes ont maintenant deux ou plusieurs niveaux de cache,
par exemple : (i) mémoire (SRAM) de premier niveau, sur la même puce que le processeur ; (ii)
mémoire de deuxième niveau, souvent sur une autre puce, mais avec un temps d’accès inférieur au
temps d’accès à la mémoire.
Métriques de performance 335

d’autres mots, le cas idéal est lorsque les p processeurs sont utilisés à 100 % — ce
qui est très rare!.
Signalons aussi que, en pratique, on mesurera l’efficacité comme on le fait pour
l’accélération, c’est-à-dire, en variant le nombre de threads et en définissant p comme
le nombre de threads utilisés.

6.5 Dimensionnement (scalability )


On dit d’un algorithme qu’il est dimensionnable (scalable) lorsque le niveau de
parallélisme augmente au moins de façon linéaire avec la taille du problème. On dit
d’une architecture qu’elle est dimensionnable si la machine continue à fournir les
mêmes performances par processeur lorsque l’on accroît le nombre de processeurs.3
L’importance d’avoir un algorithme et une machine dimensionnables provient du
fait que cela permet de résoudre des problèmes de plus grande taille sans augmenter
le temps d’exécution simplement en augmentant le nombre de processeurs.

6.6 Coûts des communications


Un algorithme est dit distribué lorsqu’il est conçu pour être exécuté sur une ar-
chitecture composée d’un certain nombre de processeurs — reliés par un réseau,
où chaque processeur exécute un ou plusieurs processus —, et que les processus
coopèrent strictement en s’échangeant des messages.
Dans un tel algorithme, il arrive fréquemment que le temps d’exécution soit
largement dominé par le temps requis pour effectuer les communications entre pro-
cesseurs — tel que décrit à la Section 6.7, le coût d’une communication peut être
de plusieurs ordres de grandeur supérieur au coût d’exécution d’une instruction nor-
male. Dans ce cas, il peut alors être suffisant d’estimer la complexité du nombre
de communications requis par l’algorithme. En d’autres mots, les communications
deviennent les opérations «barométriques».

6.7 Sources des surcoûts d’exécution parallèle


Idéalement, on aimerait, pour une machine parallèle à n processeurs, obtenir une
accélération qui soit ≈ n et une efficacité qui soit ≈ 1. En pratique, de telles
performances sont très difficiles à atteindre. Plusieurs facteurs entrent en jeu et ont
pour effet de réduire les performances :
3
«Scalability is a parallel system’s ability to gain proportionate increase in parallel speedup with
the addition of more processors.» [Gra07].
Métriques de performance 336

• Création des threads et changements de contexte : créer un “processus” est


une opération relativement coûteuse. Les langages supportant les threads (soit
de façon directe, par ex., Java, soit par l’intermédiaire de bibliothèques, par
ex., les threads Posix en C) permettent d’utiliser un plus grand nombre de
tâches/threads à des coûts inférieurs, puisqu’un thread est considéré comme
une forme légère de processus (lightweight process).
Qu’est-ce que le “poids” d’un processus? Un processus/thread, au sens général
du terme, est simplement une tâche indépendante (donc pas nécessairement
un process au sens Unix du terme). Un processus/thread est toujours associé
à contexte, qui définit l’environnement d’exécution de cette tâche. Minimale-
ment, le contexte d’un thread contient les éléments suivants :
– Registres (y compris le pointeur d’instruction) ;
– Variables locales (contenues dans le bloc d’activation sur la pile).

Par contre, un processus, au sens Unix du terme, contient en gros les éléments
suivants :
– Registres ;
– Variables locales ;
– Tas (heap) ;
– Descripteurs de fichiers et pipes ouverts/actifs ;
– Gestionnaires d’interruption ;
– Code du programme.

Dans le cas des threads, certains des éléments qui sont présents dans le contexte
d’un processus et qui sont requis pour exécuter les divers threads — par ex.,
le code du programme — sont plutôt partagés entre les threads, des threads
étant toujours définis dans le contexte d’un processus.
Or, la création et l’amorce d’un nouveau thread ou processus demande toujours
d’allouer et d’initialiser un contexte approprié. De plus, un changement de
contexte survient lorsqu’on doit suspendre l’exécution d’un thread (parce qu’il
a terminé son exécution, parce qu’il devient bloqué, ou parce que la tranche
de temps (time slice) qui lui était allouée est écoulée) et sélectionner puis
réactiver un nouveau thread.
Effectuer ces opérations introduit donc des surcoûts qui peuvent devenir im-
portant si, par exemple, le nombre de threads est nettement supérieur au
nombre de processeurs, conduisant ainsi à de nombreux changement de con-
texte.
Métriques de performance 337

• Synchronisation et communication entre les threads : un programme con-


current, par définition, est formé de plusieurs processus qui interagissent et
coopèrent pour résoudre un problème global. Cette coopération, et les in-
teractions qui en résultent, entraînent l’utilisation de divers mécanismes de
synchronisation (par exemple, sémaphores pour le modèle de programmation
par variables partagées) ou de communication (par ex., envois et réception de
messages sur les canaux de communication dans le modèle par échanges de
messages). L’utilisation de ces mécanismes entraîne alors des coûts supplé-
mentaires par rapport à un programme séquentiel équivalent (en termes des
opérations additionnelles à exécuter, ainsi qu’en termes des délais et change-
ments de contexte qu’ils peuvent impliquer).

• Communications inter-processeurs : les coûts de communications peuvent de-


venir particulièrement marqués lorsque l’architecture sur laquelle s’exécute le
programme est de type “multi-ordinateurs”, c’est-à-dire, lorsque les communi-
cations et échanges entre les processus/processeurs se font par l’intermédiaire
d’un réseau. Le temps nécessaire pour effectuer une communication sur un
réseau est supérieur, en gros, au temps d’exécution d’une instruction normale
par un facteur de 2–4 ordres de grandeur — donc pas 2–4 fois plus long, mais
bien 102 –104 fois plus. Les communications introduisent donc des délais dans
le travail effectué par le programme. De plus, comme il n’est pas raisonnable
d’attendre durant plusieurs milliers de cycles sans rien faire, une communica-
tion génère habituellement aussi un changement de contexte, donc des surcoûts
additionnels.

• Répartition de la charge de travail(load balancing) : la répartition du travail


(des tâches, des threads) entre les divers processeurs, tant sur une machine
multi-processeurs que sur une machine multi-ordinateurs, entraîne l’exécution
de travail supplémentaire (généralement effectué par un load balancer, qui fait
partie du système d’exécution (run-time system) de la machine).
Si les tâches sont mal réparties, il est alors possible qu’un processeur soit sur-
chargé de travail, alors qu’un autre ait peu de travail à effectuer, donc soit
mal utilisé. L’effet global d’un déséquilibre de la charge est alors d’augmenter
le temps d’exécution et de réduire l’efficacité globale. Toutefois, assurer une
répartition véritablement équilibrée de la charge de travail, particulièrement
sur une machine multi-ordinateurs, peut entraîner des surcoûts non néglige-
ables (principalement au niveau des communications requises pour monitorer
la charge et répartir le travail entre les processeurs).

• Calculs supplémentaires : l’accélération absolue se mesure relativement au


meilleur algorithme/programme séquentiel permettant de résoudre le prob-
Métriques de performance 338

lème. Or, il est possible que cet algorithme/programme séquentiel ne puisse


pas être parallélisé (intrinsèquement séquentiel, à cause des dépendances de
contrôle et de données qui le définissent). Dans ce cas, l’algorithme/le pro-
gramme parallèle pourra être basé sur une version séquentielle moins intéres-
sante, mais plus facilement parallélisable.

6.8 Lois d’Amdhal, de Gustafson–Barsis et fraction


séquentielle expérimentale
Remarques :

• La section qui suit est (en partie) une traduction et (en partie) une adaptation
du chapitre intitulé «Performance analysis» du livre de M.J. Quinn «Parallel
Programming in C with MPI and OpenMP » [Qui03, Chap. 7].
• Parmi les modifications, des détails supplémentaires ont été ajoutés pour les
manipulations algébriques, et certains éléments de notation ont été simplifiés
ou modifiés pour refléter la notation utilisée dans la première partie des notes
de cours :
Notation de Quinn Notation utilisée
Temps partie σ(n) σ
séquentielle
Temps partie ϕ(n) ϕ
parallèle
Accélération ψ(n, p) ψ

Temps pour T (n, p) T (p, n)


taille n et p
processeurs
Métriques de performance 339

6.8.1 Temps d’exécution, partie séquentielle, partie parallèle


et accélération
Le temps d’exécution d’un programme paralléle pour un problème de taille n avec
p processeurs — en ignorant les (sur)coûts des synchronisations et communications
— peut être décomposé en deux parties :4
• σ = partie intrinsèquement séquentielle
• ϕ = partie pouvant s’exécuter en parallèle
Le temps pour un processeur est le suivant :

T (1, n) = σ + ϕ (6.1)

Le temps pour p processeursest alors le suivant :


ϕ
T (p, n) = σ +
p

L’accélération est alors bornée comme suit :


σ+ϕ
ψ ≤ (6.2)
σ + ϕ/p

6.8.2 Loi d’Amdahl


Note : Gene Amdahl était un architecte (matériel) pour IBM, qui a ensuite a fondé
sa propre compagnie de conception et construction de machines (gros mainframes).
Notons par f la fraction des opérations d’un calcul qui doivent être exécutées
de façon séquentielle, avec0 ≤ f ≤ 1, donc définie comme suit :
σ
f =
σ+ϕ
On a alors, par simples manipuations algébriques :
σ
σ+ϕ =
f
Et :
σ 1
ϕ = − σ = σ( − 1)
f f
4
Petit truc mnémonique : σ = sigma = séquentielle. ϕ = phi = paralléle.
Métriques de performance 340

En remplaçant ces deux identités pour σ et ϕ dans la formule (6.2) de l’accélération,


on obtient ce qui suit :
σ+ϕ
ψ ≤
σ + ϕ/p
σ/f
=
σ + σ(1/f − 1)/p
1/f
=
1 + (1/f − 1)/p
1
=
f + (1 − f )/p

C’est cette relation qu’on appelle la loi d’Amdahl [Amd67].

Définition 10 (Loi d’Amdahl) Soit f la fraction des opérations qui doivent être
exécutées de façon séquentielle, avec 0 ≤ f ≤ 1. Alors, l’accélération maximale ψ
pouvant être obtenue avec p processeurs est la suivante :

1
ψ ≤
f + (1 − f )/p

Exemples :

• Soit un programme pour lequel on détermine que 90 % du temps d’exécution


est pour du code qui pourrait s’exécuter en parallèle. Alors, l’accélération
maximale pouvant être obtenue sur une machine à huit (8) processeurs sera la
suivante :
1
ψ≤ ≈ 4.7
0.1 + (1 − 0.1)/8

• Soit un programme pour lequel on détermine que 75 %du temps d’exécution


est pour du code qui pourrait s’exécuter en parallèle. Alors, l’accélération
maximale pouvant être obtenue sur une machine parallèle sans limite au nom-
bre de processeurs sera la suivante :
1
lim ≈4
p→∞ 0.25 + (1 − 0.25)/p
Métriques de performance 341

Limites de la loi d’Amdahl


La formulation de la loi d’Amdahl ignore les surcoûts associés à l’exécution parallèle,
c’est-à-dire, les coûts des synchronisations et communications entre processus. De
façon plus détaillée, donc, le temps d’exécution d’un programme parallèle devrait
être définie comme suit, où κ(n, p) représente les surcoûts (overhead ) de l’exécution
parallèle :

ϕ(n)
T (p, n) = σ(n) + + κ(n, p)
p

Si on tient compte de ces surcoûts, qui augmentent lorsque le nombre de pro-


cesseurs augmente, alors l’accélération serait encore plus faible que celle prédite par
la loi d’Amdahl /

L’effet Amdahl
Règle générale, la complexité de κ(n, p) est plus faible que celle de ϕ(n). C’est-à-dire
que le temps d’exécution augmente plus rapidement (en fonction de n) que le temps
de synchronisation et communication (en fonction de n et p).
Donc, pour un nombre fixe de processeurs, l’accélération va généralement aug-
menter lorsqu’on augmente la taille du problème. En d’autres mots, plus la taille
du problème est grande, plus l’accélération est grande.

6.8.3 Loi de Gustafson–Barsis


La loi d’Amdahl suppose que l’objectif essentiel d’une exécution parallèle est de
produire le résultat le plus rapidement possible. On considère alors un prob-
lème de taille fixe, puis on examine l’accélération obtenue en augmentant le nombre
de processeurs.
Toutefois, dans plusieurs domaines, l’objectif n’est pas nécessairement d’obtenir
la réponse plus rapidement. L’objectif peut aussi être, pour une période de temps
(durée) donné, de traiter un problème de plus grande taille — exemples : prévisions
météos, où on augmente la précision des résultats en travaillant avec des grilles plus
fines, donc comportant un plus grand nombre de points, une plus grande quantité
de données. En d’autres mots, il est faux de penser que la taille du problème est
indépendante du nombre de processeurs.
Pour en arriver à la loi de Gustafson–Barsis, plutôt que supposer que la taille
du problème est fixe, on va supposer que le temps d’exécution parallèle est fixe. De
plus, on prend aussi comme hypothèse que la taille de la fraction intrinsèquement
séquentielle n’augmente pas lorsque la taille du problème augmente.
Métriques de performance 342

Supposons qu’on fixe la durée d’exécution. Supposons ensuite qu’on augmente la


taille du problème au fur et à mesure où on augmente le nombre de processeurs. Or,
l’effet Amdahl nous indique que pour un nombre fixe de processeurs, si on augmente
la taille du problème, alors l’accélération va augmenter. Or, l’accélération augmente
aussi lorsqu’on augmente le nombre de processeurs. Donc, si on augmente à la fois la
taille du problème et le nombre de processeurs, alors l’accélération en sera d’autant
plus augmentée!
Intuitivement, l’effet Amdahl s’explique par le fait que lorsqu’on augmente la
taille du problème, la fraction du temps d’exécution de la partie intrinsèquement
séquentielle diminue. Donc, lorsqu’on ajoute des processeurs, alors on peut traiter
des problèmes de plus grande taille. Dans ce cas, la fraction parfaitement par-
allélisable augmente, donc la fraction intrinsèquement séquentielle diminue, donc
l’accélération augmente :
On ajoute des processeurs ⇒ On peut traiter des problèmes plus gros

⇒ La fraction parfaitement parallélisable aug-


mente

⇒ La fraction intrinsèquement séquentielle


diminue

⇒ L’accélération augmente
Notons par s la fraction du temps d’exécution parallèle consacrée aux opérations
séquentielles, i.e., s est définie comme suit : 5

σ
s =
σ + ϕ/p

On a alors, par simples manipulations algébriques :

σ = (σ + ϕ/p)s
5
On remarque la différence par rapport à la définition de la fraction f , où f était définie par
rapport au temps séquentiel (pour un processeur) et non par rapport au temps parallèle (pour p
processeurs) :
σ
f =
σ+ϕ
Donc, alors que f était la fraction du temps pour l’exécution de la partie intrinsèquement séquen-
tielle sur une machine uniprocesseur, s est la fraction du temps pour l’exécution de la partie
intrinsèquement séquentielle sur machine parallèle à p processeurs.
Métriques de performance 343

6
Et :

ϕ = (σ + ϕ/p)(1 − s)p

Donc, en substituant ces deux dernières identités dans le numéreateur de la


formule (6.2) de l’accélération et en factorisant σ + ϕ/p :
σ+ϕ
ψ ≤
σ + ϕ/p
(σ + ϕ/p)(s + (1 − s)p)
=
σ + ϕ/p
= s + (1 − s)p
= s + p − sp
= p + (1 − p)s

C’est cette relation qu’on appelle la loi de Gustafson–Barsis [Gus88].

Définition 11 (Loi de Gustafson–Barsis) Pour un problème de taille n traité


avec p processeurs, soit s la fraction du temps d’exécution (parallèle) consacrée à
la partie intrinsèquement séquentielle (avec 0 ≤ s ≤ 1). Alors, l’accélération
maximale ψ pouvant être obtenue est la suivante :

ψ ≤ p + (1 − p)s

Cette accélération peut donc être inteprétée comme étant définie comme suit
(voir Figure au tableau) :7

Temps que l’exécution du programme aurait pris avec un seul processeur


Temps que l’exécution a vraiment pris avec p processeurs
L’accélération prédite par la loi de Gustafson–Barsis est aussi appelée scaled
speedup, parce qu’en utilisant le temps parallèle comme point de comparaison (pour
déterminer la fraction séquentielle) plutôt que le temps séquentiel, on fait en sorte
que la taille du problème soit une fonction croissante du nombre de processeurs.
6
On a que :
σ + ϕ/p σ ϕ/p
1= = + = s + (1 − s)
σ + ϕ/p σ + ϕ/p σ + ϕ/p
7
En supposant que le processeur a suffisamment d’espace mémoire.
Métriques de performance 344

Signalons que Gustafson [Gus88] a formulé cette loi après avoir constaté, ex-
périmentalement, que certains problèmes s’exécutaient de façon très efficace, i.e.,
avec une forte accélération, sur des machines avec un grand nombre de processeurs
(accélération ≈ 1016–1020 pour 1024 processeurs).
Exemple :

• Soit un programme s’exécutant en 220 secondes sur 64 processeurs. Des


mesures et expérimentations (benchmarking, profilage) permettent de déter-
miner que 5 % du temps d’exécution est consacré à des parties intrinsèquement
séquentielles. Quelle sera alors l’accélération (scaled speedup) obtenue par ce
programme?

ψ = 64 + (1 − 64)(0.05) = 64 − 3.15 = 60.85

Analogie pour comparer la loi d’Amdahl et la loi de Gustafson


L’analogie suivante permet d’illustrer la différence entre la loi d’Amdahl et la loi de
Gustafson8 . Soit une automobile qui se déplace à 30 km/h d’une ville A vers une
ville B, distantes l’une de l’autre de 60 km. Supposons que la voiture roule déjà
depuis une heure, donc a franchi 30 km.

• La loi d’Amdahl suggère que peu importe la vitesse à laquelle on se déplace


durant la deuxième partie du trajet, il sera impossible d’arriver à une moyenne
de 90 km/h — même si l’automobile se déplaçait à une vitesse infinie, la vitesse
moyenne serait alors de 60 km/h.
• La loi de Gustafson suggère qu’avec suffisamment de temps et une distance
assez longue, alors il sera toujours possible d’arriver à une vitesse moyenne de
60 km/h. Par exemple, si l’automobile se déplace à 150 km/h pendant une
heure, alors on atteindra la vitesse moyenne de 60 km/h.

8
http://en.wikipedia.org/wiki/Gustafson’s_law
Métriques de performance 345

6.8.4 Accélération selon la loi d’Amdhal vs. selon la loi de


Gustafson-Barsis
Le tableau 6.1 présente l’accélération obtenue pour diverses valeurs de f (loi d’Amdhal)
ou s (loi de Gustafson–Barsis) et divers nombres de processeurs.

Nb. Amdhal Gustafson Amdhal Gustafson Amdhal Gustafson


procs.
f ou s 0,05 0,05 0,10 0,10 0,20 0,20
2 1,90 1,95 1,82 1,90 1,67 1,80
4 3,48 3,85 3,08 3,70 2,50 3,40
8 5,93 7,65 4,71 7,30 3,33 6,60
16 9,14 15,25 6,40 14,50 4,00 13,00
32 12,55 30,45 7,80 28,90 4,44 25,80
64 15,42 60,85 8,77 57,70 4,71 51,40
128 17,41 121,65 9,34 115,30 4,85 102,60

Tableau 6.1: Accélération maximale possible pour diverses valeurs de f ou s et


divers nombres de processeurs.

Une autre façon de voir ces deux lois :

• Loi d’Amdhal

– Borne inférieure de l’accélération


– Hypothèse pessimiste = la fraction séquentielle reste constante, peu im-
porte la taille du problème = Le temps intrinséquement séquentiel aug-
mente au même rythme que la taille du problème /

• Gustafson-Barsis

– Borne supérieure de l’accélération


– Hypothèse optimiste = le temps intrinsèquement séquentiel reste con-
stant, peu importe la taille du problème

• La réalité. . . est quelque part entre les deux

– Le temps intrinsèquement séquentiel augmente, mais beaucoup plus lente-


ment que la taille du problème
– ⇒ si on augmente la taille du problème, alors on pourra augmenter le
nombre de processeurs pour obtenir une meilleure accélération ,
Métriques de performance 346

• Accélération Amdhal avec 4 processeurs

– 10 / (1 + (9/4)) = 10 / (1 + 2.25) = 3.08

• Accélération Gustafson–Barsis avec 4 processeurs

– (1 + 4*9) / 10 = 37 / 10 = 3.7

6.8.5 Fraction séquentielle déterminée expérimentalement


Karp & Flatt [KF90] ont proposé une «métrique» pour tenter de comprendre et
évaluer le comportement de programmes parallèles — donc en plus des métriques
d’accélération et d’efficacité.
Appelons e la fraction du temps d’exécution associée à la partie intrinsèquement
séquentielle, donc définie comme suit :
σ
e = (6.3)
T (1, n)

Alors :
σ = eT (1, n)

ϕ = T (1, n) − σ [Déduit de l’équation (6.1)]


= T (1, n) − eT (1, n) [Déduit de l’équation (6.3)]
= (1 − e)T (1, n)
Métriques de performance 347

Donc :
ϕ
T (p, n) = σ + (6.4)
p
(1 − e)T (1, n)
= eT (1, n) + (6.5)
p
Soit l’accélération9 , notée ψ, définie comme suit :
T (1, n)
ψ = (6.6)
T (p, n)
Donc :
T (p, n) = eT (1, n) + (1 − e)T (1, n)/p [De (6.5)]
= eψT (p, n) + (1 − e)ψT (p, n)/p [Déduit de (6.6)]

En divisant par T (p, n) de chaque coté, on obtient :


(1 − e)ψ
1 = eψ +
p

En divisant par ψ de chaque coté, on a alors :


1 (1 − e)
= e+
ψ p
1 e
= e+ −
p p
1 1
= e(1 − ) +
p p
Finalement, en isolant e à gauche :
1 1
ψ
− p
e = 1
1− p

Cette métrique, déterminée de façon expérimentale, à partir de l’accélération


obtenue pour un programme donné s’exécutant sur une machine avec p processeurs,
est appelée la fraction séquentielle déterminée expérimentalement.
Règle générale, pour un problème de taille donnée, lorsqu’on augmente le nombre
de processeurs, l’efficacité décroit. On peut alors utiliser cette métrique pour tenter
d’expliquer les causes de cette baisse d’efficacité :
9
Donc, accélération relative.
Métriques de performance 348

• Parce que le programme ne contient pas suffisamment de parallélisme.


• Parce que les surcoûts liés à l’exécution parallèle augmentent trop rapidement.

Exemples [KF90] :

• Pour qu’une machine soit utilisée efficacement, il faut que la charge soit bien
balancée entre les processeurs — temps de travail équivalent pour chacun des
processeurs.
Supposons qu’on a 12 tâches à distribuer. Pour p = 2, 3, 4, 6 et 12, la charge
sera bien balancée entre les processeurs. Par contre, pour d’autres valeurs
de p, la charge ne sera pas bien balancée, même si l’accélération s’améliore.
Or, un débalancement de la charge va conduire à des valeurs anormalement
croissantes de la fraction e.

• Les surcoûts de synchronisation et communication augmentent lorsqu’on ac-


croit le nombre de processeurs. Cette augmentation des surcoûts diminue
l’accélération, et conduit à un accroissement de e. Une telle augmentation de
e est alors probablement un signe que la granularité des tâches est trop fine,
i.e., qu’il y a trop de surcoûts de synchronisation et communication.
Métriques de performance 349

6.A Mesures de performances : Un exemple concret


Dans cette section, nous allons examiner un exemple concret de mesures de perfor-
mances pour un programme sur Java s’exécutant une machine à coeurs/processeurs
multiples.
Le Programme Java 6.1 présente un programme Java permettant d’approximer
la valeur de π à l’aide d’une méthode Monte Carlo avec plusieurs threads — un
programme avec parallélisme semi-embarrassant. Il s’agit d’une version Java d’une
fonction Ruby vue précédemment : Programme Ruby 5.10 (style impératif). Voir
la Section 12.4 pour les explications concernant cette mise en oeuvre Java.

public static int nbDansCercleSeq ( int nbLancers ) {


Random rnd = new Random ();
int nb = 0;
for ( int k = 0; k < nbLancers ; k ++ ) {
double x = rnd . nextDouble ();
double y = rnd . nextDouble ();
if ( x * x + y * y <= 1.0 ) {
nb += 1;
}
}
return nb ;
}
Métriques de performance 350

Programme Java 6.1 Une fonction parallèle evaluerPi (et sa fonction auxiliaire
nbDansCercleSeq) pour approximer la valeur de π à l’aide de la méthode de Monte
Carlo à plusieurs threads.
@SuppressWarnings ( " unchecked " )
public static double evaluerPi ( final int nbLancers ,
int nbThreads ) {
ExecutorService pool =
Executors . newFixedThreadPool ( nbThreads );

Future < Integer > lesNbs [] = new Future [ nbThreads ]; // unchecked cast
for ( int k = 0; k < nbThreads ; k ++ ) {
lesNbs [ k ] = pool . submit ( () -> {
return nbDansCercleSeq ( nbLancers / nbThreads );
} );
}

int nbTotalDansCercle = 0;
for ( int k = 0; k < nbThreads ; k ++ ) {
try { nbTotalDansCercle += lesNbs [ k ]. get (); }
catch ( Exception ie ) {}
}
pool . shutdown ();

return 4.0 * nbTotalDansCercle / nbLancers ;


}
Métriques de performance 351

Figure 6.7: Graphe donnant le temps d’exécution du programme Pi.java pour dif-
férents nombres de threads. Les temps sont indiqués pour trois (3) séries différentes
d’exécution.

La Figure 6.7 présente les temps d’exécution pour trois séries distinctes d’exécution
du programme Java 12.2, et ce en faisant varier le nombre de threads — 1, 2, 4, 8,
16, . . . , 1024 threads. Dans tous les cas, on effectue un total de 10 000 000 lancers
— donc 10 000 000 lancers partagés entre les divers threads.
Quelques remarques sur ces expérimentations et résultats :

• Le programme a été exécuté sur une machine Linux avec 8 coeurs.

• L’échelle des x, qui indique le nombre de threads Java, est logarithmique. C’est
ce qui explique que l’écart entre les valeurs indiquées sur cet axe est constant,
même si à chaque fois on double le nombre de threads.
Métriques de performance 352

• Le temps pour une exécution avec un certain nombre de threads n’est pas
toujours le même — il varie d’une fois à une autre. Cela est tout à fait normal
et usuel, car plusieurs facteurs peuvent influencer le temps d’exécution —
ordre différent de préemption des threads, temps variable d’accès au cache ou
à la mémoire, etc.

• On constate que, au début, lorsqu’on augmente le nombre de threads, en gros


le temps d’exécution diminue. C’est le cas même lorsqu’on utilise plus de
threads que le nombre de processeurs/coeurs!
Par contre, lorsque le nombre de threads devient trop grand, alors le temps
d’exécution recommence à augmenter! C’est le phénomène de la courbe en U
décrit à la section 7.4.

• On remarque qu’il y a une certaine flexibilité quant au nombre de threads :


le temps d’exécution semble le plus bas lorsqu’on utilise entre 8 et 64 threads.
Donc, la valeur choisie pour le nombre de threads n’a pas à être exacte et
précise — on a un certain jeu. Par contre, si ce nombre est trop petit ou trop
grand, alors les résultats sont moins bons /
Soulignons que ce n’est que de façon expérimentale que l’on peut déterminer
les meilleures valeurs pour ce genre de paramètres : il n’y a pas de formule
magique pour trouver la ou les bonne valeurs, car trop de facteurs entrent en
jeu.

figures 6.8 et 6.9, quant à elles, présentent les deux graphes suivants :

• Le graphe d’accélération relative : ce graphe indique, pour les divers nombres


de threads nbt, de combien de fois cette version avec nbt threads est plus rapide
que la version avec un (1) thread.

• Le graphe d’efficacité : ce graphe indique, pour les divers nombres de threads,


l’efficacité — le pourcentage d’utilisation des ressources de la machine. Comme
il s’agit d’une machine à 8 processeurs, on aimerait que le programme, dans le
meilleur des cas, puisse s’exécuter 8 fois plus rapidement. Malheureusement,
ce n’est pas le cas — et c’est rarement le cas : ici, dans le meilleur des cas, soit
2.6
avec 32 threads, l’accélération est de 2.6, donc une efficacité de 32.5 % ( ).
8
Métriques de performance 353

Figure 6.8: Graphe d’accélération du programme Pi.java pour différents nombres


de threads— moyenne de trois (3) éxécutions.
Métriques de performance 354

Figure 6.9: Graphe d’efficacité du programme Pi.java pour différents nombres de


threads— moyenne de trois (3) éxécutions, pour huit (8) processeurs.
Chapitre 7

Méthodologie pour la
programmation parallèle et patrons
d’algorithmes

7.1 Introduction
Ce chapitre présente tout d’abord une «méthodologie» — en fait, plutôt une «heuris-
tique» — pour la programmation parallèle en mémoire partagée — donc avec threads
— qui s’inspire de l’approche PCAM présentée par I. Foster [Fos95] :
http://wotug.org/parallel/books/addison-wesley/dbpp/

Ce chapitre introduit ensuite les principaux patrons d’algorithmes parallèles présen-


tés par Sottile, Mattson, et Rasmussen [SMR09].

355
Méthodologie de programmation parallèle 356

7.2 Approche PCAM


L’approche PCAM de Foster a été conçue plus particulièrement pour un modèle de
programmation parallèle par échanges de messages — donc en mémoire distribuée,
avec des processus plutôt que des threads. Toutefois, oOn peut quand même s’en
inspirer pour la programmation parallèle avec threads communiquant par variables
partagées.

7.2.1 Objectifs
Les objectifs (parfois contradictoires) qui sont visés par l’approche PCAM sont les
suivants :

• Réduire les coûts de synchronisation et communication entre les threads.

• Distribuer le travail de façon la plus uniforme possible entre les threads —


on dit aussi «équilibrer la charge» entre les threads (et processeurs) (load
balancing).

• Conserver une flexibilité quant à la capacité de dimensionnement du pro-


gramme (scalability) — donc, si on ajoute des processeurs, alors on devrait
pouvoir augmenter l’accélération.

7.2.2 Étapes de l’approche PCAM


Les principales étapes de l’approche PCAM sont les suivantes :

1. P artitionnement : On identifie tout le parallélisme disponible. À cette étape,


on ne soucie pas de la granularité. En fait, on vise même à obtenir des tâches
de très fine granularité — donc aussi parallèle que possible.

2. C ommunication : On identifie les liens et dépendances entre les diverses


tâches.

3. A gglomération : On procède au regroupement des tâches de fine granularité


pour obtenir des tâches de plus forte granularité.

4. M apping : On associe les diverses tâches résultantes à des threads.


Méthodologie de programmation parallèle 357

7.3 Méthodologie de programmation parallèle avec


threads inspirée de l’approche PCAM
1. On commence par identifier les tâches les plus fines possibles (granularité
aussi fine que nécessaire en fonction du problème), sans tenir compte des
ressources ou contraintes de la machine — en d’autres mots, on suppose une
machine «idéale» avec des ressources illimitées.
Stratégies typiques = parallélisme de données, de résultat, diviser-pour-régner
récursif, etc. Voir section 7.5

2. On analyse les dépendances entre les tâches


Par exemple, on peut produire un graphe des dépendances entre les tâches.

3. Si nécessaire, on agglomère (sur la base des dépendances) un groupe de tâches


de fine granularité, de façon à produire des tâches de plus grosse granularité,
ce qui aura aussi pour effet de réduire le nombre de tâches. Par exemple : re-
groupement par blocs d’éléments adjacents, par groupes d’éléments distribués
cycliquement, d’un vecteur ou d’une matrice, etc.

4. On associe les tâches à des threads :

(a) Association via parallélisme récursif :


Si l’algorithme sous-jacent est naturellement récursif, on peut alors utiliser
du parallélisme récursif, où chaque instance récursive traite une sous-
tâche.
Pour limiter le nombre de threads et augmenter la granularité, on peut
utiliser un seuil de récursion, qui indique quand passer d’une solution
parallèle récursive à une solution séquentielle (récursive ou itérative). Ce
seuil peut être fondé sur la taille du sous-problème à traiter ou sur le
niveau de la récursion — après avoir atteint une certaine profondeur
dans l’arbre de récursion parallèle, on cesse de créer des threads.
(b) Association statique :
Si toutes les tâches sont connues de façon statique — c’est-à-dire que le
fait d’exécuter une tâche ne génère pas de nouvelles tâches — et si toutes
les tâches requièrent sensiblement la même quantité de travail (sensible-
ment le même temps pour les traiter), alors on peut utiliser une asso-
ciation statique tâche/thread, i.e., on lance un thread pour chaque
tâche.
Méthodologie de programmation parallèle 358

(c) Association dynamique :


Si i) le travail requis pour les différentes tâches varie grandement d’une
tâche à une autre ou si ii) le nombre de tâches varie dynamiquement
(exécuter une tâche peut en créer de nouvelles), alors on peut utiliser une
association dynamique tâche/thread : on crée un nombre fixe de threads,
qui vont exécuter une tâche à la fois en fonction de leur disponibilité — en
utilisant l’option «dynamic: k» ou un «sac de tâches»pour représenter
les tâches à traiter.
Dans un tel cas, il est important que le nombre de tâches soit plus
grand (grosso modo, au moins un ordre de grandeur plus grand) que
le nombre de threads. . . sinon la distribution du travail entre les threads
pourrait ne pas être uniforme.

Le pseudocode 7.1 présente, dans un pseudocode informel, une heuristique pour


la conception d’un programme parallèle avec threads.
Méthodologie de programmation parallèle 359

DEBUT

Décomposer le problème dans le plus grand nombre possible


de tâches indépendantes
- Décomposition des données (parallélisme de résultat)?
- Décomposition fonctionnelle (parallélisme de spécialistes)?

SI nombre de taches > nombre de processeurs ALORS

SI les diverses taches sont similaires entre elles ALORS


Agglomérer les taches (granularité fine ou moyenne) pour en faire des
taches de plus grande granularité (moyenne ou grossière)
FIN

SI la quantité de travail est +/- la même pour chaque tache ALORS

SI nombre de taches = nombre de processeurs ALORS


⇒ Association statique tache/processeur
SINON # Nombre de taches > nombre de processeurs
⇒ Association statique et cyclique tache/processeur ?
⇒ Association dynamique tache/processeur (sac de taches) ?
FIN

SINON # quantité de travail variable


⇒ Association statique et cyclique tache/processeur ?
⇒ Association dynamique tache/processeur (sac de taches) ?
FIN

SINON # nombre de taches ≤ nombre de processeurs

SI les données traitées sont des flux de donnees ALORS


⇒ Parallélisme de specialistes/de flux
SINON
⇒ Pas grand chose à faire /
FIN

FIN

FIN

Pseudocode 7.1: Heuristique pour la conception d’un programme parallèle avec


threads.
Méthodologie de programmation parallèle 360

Figure 7.1: Arbre de décision pour choisir entre une stratégie statique vs. dynamique
d’association (mapping) des tâches aux threads.
Source : http://www.dais.unive.it/~calpar/

La Figure 7.1 présente un «d’arbre de décision» pour déterminer si une approche


statique d’association des tâches aux threads est appropriée ou si une approche
dynamique est préférable. . . ou même nécessaire (première branche droite).
Une approche statique est généralement plus efficace parce qu’elle génère moins
de surcoûts de communication et de synchronisation. Toutefois, une approche sta-
tique n’est appropriée que si les conditions suivantes sont satisfaites :
1. Toutes les tâches peuvent être identifiées de façon statique, c’est-à-dire, aucune
nouvelle tâche n’est générée en cours d’exécution.
2. Le coût de chacune des tâches est connu.
3. Les coûts des diverses tâches sont sensiblement uniformes et les unités de
traitement possèdent toutes la même puissance de traitement.
Les deux dernières conditions font en sorte qu’on pourra être (relativement)
assuré que les unités de traitement termineront (presque) toutes en même temps,
donc sans qu’il n’y ait d’unité qui attend à ne rien faire que les autres unités aient
terminé.
Méthodologie de programmation parallèle 361

7.4 Effet de la granularité sur les performances


Dans cette approche de programmation parallèle, un point important à garder à
l’esprit est le suivant. À l’étape d’Agglomération, on regroupe des tâches de gran-
ularité (possiblement) «très fine» — tâches obtenues à l’étape de Partitionnement
— pour générer des tâches de granularité «plus grossière». On fait de tels regroupe-
ments pour réduire les coûts associés à la gestion d’un trop grand nombre de petites
tâches.
Par contre, il est important aussi de tenir compte que si on réduit le nombre de
tâches de façon trop importante et qu’on obtient un très (trop!?) petit nombre de
tâches de forte granularité, alors les performances peuvent se dégrader /
La figure 7.2 illustre l’effet typique de la granularité des tâches d’un programme
parallèle sur le temps d’exécution, alors que la figure 7.3 illustre l’effet sur les per-
formances (e.g., accélération) de ce programme — un temps d’exécution plus petit
implique de meilleures performances!
Un tel comportement des performances (ou même d’autres facteurs) avec courbe
en U inversé est fréquent, et ce pas uniquement pour la taille des grains. Par exem-
ple, pour plusieurs problèmes, lorsqu’on garde la taille du problème fixe, au début,
lorsqu’on augmente le nombre de processeurs, le temps d’exécution peut diminuer.
Toutefois, si on continue d’augmenter le nombre de processeurs, le temps d’exécution
va se stabiliser, puis commencer à augmenter lorsque le nombre de processeurs de-
vient trop grand /
Méthodologie de programmation parallèle 362

Figure 7.2: Courbe illustrant l’effet général de la taille des grains (des tâches) sur
le temps d’exécution.

Figure 7.3: Courbe illustrant l’effet général de la taille des grains (des tâches) sur
la performance.
Méthodologie de programmation parallèle 363

7.5 Patrons d’algorithmes parallèles


Divers auteurs ont présenté des «patrons» pour développer des algorithmes paral-
lèles [Lea00, SSRB00, MSM05, SMR09].
Dans ce chapitre, nous allons faire un survol des patrons présentés dans «In-
troduction to Concurrency in Programming Languages» [SMR09]. Il s’agit ici de
«patrons de conception», donc de stratégies de conception d’algorithmes —
et non pas des constructions de programmation parallèle : un patron d’algorithme
peut souvent être mis en oeuvre par plusieurs patrons de programmation. Ces
stratégies nous permettent, lors de l’étape Pde la méthode PCAM, d’identifier les
tâches qui peuvent s’exécuter en parallèle.
Un algorithme parallèle implique nécessairement le fait d’exécuter plusieurs cal-
culs en même temps. On peut classer les algorithmes parallèles en trois (3) grandes
catégories, selon la façon dont les calculs à faire en parallèle sont identifiés :

7.5.1 Parallélisme de tâches (task parallelism) : on dispose d’un grand nombre


de tâches pouvant s’exécuter en parallèle.

7.5.2 Parallélisme de données (data parallelism) : on dispose d’une grande quan-


tité de données sur lesquelles on applique la même série d’opérations sur
plusieurs éléments de données.

7.5.3 Parallélisme de flux (flow parallelism ou stream parallelism) : on dispose


d’un flux de données qu’on doit traiter, avec un traitement décomposé en
plusieurs étapes — comme une chaîne de montage.

7.5.1 Parallélisme de tâches


Dans certains systèmes complexes, l’architecture du système est naturellement com-
posée d’un grand nombre de composants relativement indépendants qui interagis-
sent entre eux. Dans un tel cas, chaque composant peut être associé à une tâche
indépendante s’exécutant de façon concurrente et parallèle.
La figure 7.4 illustre l’architecture d’un système pour la modélisation du climat.1
La parallélisation de ce système pourrait se faire en associant une ou plusieurs tâches
à chacune des composantes.
1
À ne pas confondre avec la météo. Alors que la modélisation de la météo est un processus
à court terme (quelques jours), la modélisation du climat est un processus à long terme (années,
décennies, etc.), qui implique un nombre de facteurs beaucoup plus grand que la météo.
Méthodologie de programmation parallèle 364

Figure 7.4: Architecture d’un système pour la modélisation du cli-


mat (source : http://www.cs.toronto.edu/~sme/PMU199-climate-computing/
pmu199-2012F/coupled-architecture.jpg).
Méthodologie de programmation parallèle 365

Parallélisme «embarrassant»
Certains problèmes peuvent facilement être décomposés en un grand nombre de
parties indépendantes, donc où chaque partie peut s’exécuter en parallèle, sans
aucune contrainte ou dépendance. On parle alors de parallélisme «embarrassant»
(embarassingly parallel ).

Figure 7.5: Représentation graphique de l’ensemble de Mandelbrot.

La figure 7.5 présente une représentation graphique de l’ensemble de Mandelbrot,


image contenant 1 Mega pixels (1000 × 1000). La couleur associée à chaque pixel
dépend uniquement du coordonnée du pixel. Ceci implique que tous les pixels
peuvent être calculés en parallèle.
Lorsqu’on présente des algorithmes ou programmes parallèles, on utilise souvent
des graphes de dépendances de tâches pour illustrer les diverses tâches à exécuter
et les dépendances — de données ou de contrôle — qui existent entre ces diverses
tâches. Plus le nombre de dépendances est élevé, plus les possibilités d’exécution
parallèle sont restreintes.
Méthodologie de programmation parallèle 366

Figure 7.6: Graphe de dépendances de tâches pour un problème avec parallélisme


embarrassant.

Dans un problème avec parallélisme embarrassant, le graphe de tâches possède


typiquement l’allure de la figure 7.6. Dans le cas du calcul de l’ensemble de Man-
delbrot, en théorie, chaque point pourrait être associé à une tâche distincte traitée
par un thread indépendant. Toutefois, ce ne serait pas nécessairement une solution
idéale :

• Parce que créer et gérer une tâche parallèle et un thread peut être relativement
coûteux. Pour la figure 7.5, utiliser une tâche parallèle et un thread pour
chaque pixel impliquerait d’avoir un million de threads!

• Lorsqu’on a un grand nombre de tâches indépendantes qui s’exécutent vrai-


ment en parallèle, le temps total d’exécution est alors le temps requis pour
l’exécution de la plus grosse tâche.
Par exemple, supposons qu’on ait un programme décomposé en 1000 tâches
traités par 1000 threads qui s’exécutent sur 1000 processeurs. Supposons que
999 de ces tâches s’exécutent en 1 ms alors qu’une autre tâche, plus complexe,
s’exécute en 100 ms. Dans ce cas, le temps total d’exécution serait de 100 ms. . .
et pendant 99 ms, on aurait 999 processeurs qui seraient inutilisés, en attente
que le dernier thread, donc le programme, se termine.

Dans un problème avec parallélisme embarrassant — et, en fait, pour de nom-


breux autres problèmes — il est important soit que les tâches soient de même taille,
soit que l’on utilise un mécanisme qui permette de sélectionner de façon dynamique
les tâches à exécuter, pour éviter que des processeurs soient sous-utilisés. C’est ce
qu’on appelle le problème de répartition de la charge et de répartition des tâches.
Ces questions ont été abordées dans les sections qui précèdent — étape A (ag-
glomération) et étape M (mapping).
Méthodologie de programmation parallèle 367

Parallélisme «semi-embarrassant»
Un problème avec parallélisme «semi-embarrassant», comme pour le cas précé-
dent, peut être décomposé en un grand nombre de tâches qui sont indépendantes,
mais pas tout à fait complètement : les tâches interagissent, mais de façon limitée,
et souvent uniquement vers la fin de l’exécution des tâches.

Figure 7.7: Graphe de dépendances de tâches pour un problème avec parallélisme


semi-embarrassant — approximation de π par une méthode Monte Carlo.

Un problème relativement simple ayant cette propriéte est celui visant à estimer
la valeur de π à l’aide d’une méthode de Monte Carlo : voir section 5.2.3 (p. 248). Le
graphe de dépendances des tâches ressemble alors à celui de la figure 7.7 : le gros du
travail — la simulation des lancers — se fait de façon complètement indépendante
par chacun des threads, et ce n’est qu’à la toute fin qu’il suffit d’additionner les
résultats calculés par chacun d’entre eux!
Méthodologie de programmation parallèle 368

Parallélisme récursif
De nombreux problèmes peuvent être résolus par un algorithme récursif, et ce en
utilisant l’approche «diviser-pour-régner» — voir sections A.1 et 5.2 pour plus de
détails. L’approche diviser-pour-régner peut être décrite comme suit :
SI le problème est simple ALORS
On trouve la solution directement
SINON
On décompose le problème en sous-problèmes
On résout récursivement les sous-problèmes
On combine les solutions des sous-problèmes
pour obtenir la solution du problème initial
FIN

On parle d’une approche «diviser-pour-régner dichotomique» lorsqu’on décom-


pose le problème en deux (2) sous-problèmes.L’algorithme récursif a alors l’allure
du Pseudocode 7.2.

FONCTION resoudre_dpr_2( probleme )


DEBUT
SI est_simple( probleme ) ALORS
# Cas non-récursif
RETOURNER resoudre_probleme_simple( probleme )
SINON
# Cas récursifs avec deux sous-problèmes
prob1, prob2 = decomposer( probleme )

sol1 = resoudre_dpr_2( prob1 )


sol2 = resoudre_dpr_2( prob2 )

RETOURNER combiner_solutions( sol1, sol2 )


FIN
FIN

Pseudocode 7.2: Pseudocode décrivant un algorithme récursif dichotomique (dé-


composition en deux (2) sous-problèmes).

Un algorithme récursif s’obtient alors facilement lorsque les deux sous-problèmes


sont disjoints — donc indépendants : il suffit d’exécuter en parallèle les deux appels
à resoudre_dpr_2! Dans un tel cas, on obtient alors un graphe de dépendances tel
que celui présenté à la Figure 7.8 — beaucoup de parallélisme donc, en fait, souvent
trop!
Méthodologie de programmation parallèle 369

Figure 7.8: Graphe de dépendances de tâches pour un problème avec parallélisme


récursif dichotomique — appels récursifs à resoudre_dpr_2.
Méthodologie de programmation parallèle 370

Patron de conception d’algorithme vs. patron de programmation


Bien qu’il existe souvent une correspondance assez directe entre un patron de con-
ception et un patron de programmation, ceci ne signifie pas que ce patron
de programmation soit nécessairement la meilleure solution.. . . mais plusieurs
patrons de programmation peuvent aussi être plus utilisés
Exemple : Un problème pourrait avoir une solution naturellement récursive,
donc suggérant un patron d’algorithme «diviser-pour-régner» avec récursion. Par
contre, une mise en oeuvre avec du parallélisme de style fork-join pourrait ne pas
convenir :

• Ce mécanisme n’est pas supporté par le langage cible /

• Les surcoûts associés à la création dynamique de threads sont trop élevés /

• Etc.

Mais, comme on l’a vu dans un exercice fait précédememnt, il est tout à fait
possible de mettre en oeuvre une stratégie diviser-pour-régner. . . sans utiliser de
récursion — par exemple, avec un pool de threads et un sac de tâches.

7.5.2 Parallélisme de données


Lin et Snyder [LS09] définissent un calcul avec parallélisme de données comme
suit :

un calcul avec parallélisme de données en est un dans lequel le paral-


lélisme est obtenu en exécutant, en même temps, une même opération sur
différents items de données ; la quantité de parallélisme augmente
donc de façon proportionnelle à la quantité de données.

Dans les premières machines parallèles de type SIMD — Single Instruction, Mul-
tiple Data — une telle forme de parallélisme était le fondement même des calculs
parallèles — en fait, la seule forme exploitable de parallélisme. Toutefois, une par-
ticularité de ces machines, qu’on ne cherche pas à reproduire lorsqu’on développe des
algorithmes avec parallélisme de données, est le fait que dans une machine SIMD,
c’est exactement la même instruction qui s’exécute sur tous les processeurs en
même temps, de façon synchrone.
Dans les machines modernes où le parallélisme de données est aussi le fondement
des calculs — par exemple les GPU — le modèle SIMD a été assoupli et chaque unité
d’exécution va exécuter le même segment de code — souvent appelé kernel — donc
la même procédure. On se rapproche donc d’un modèle SPMD — Single Program,
Méthodologie de programmation parallèle 371

Multiple Data — utilisé dans plusieurs langages de programmation modernes, par


exemple, MPI [Pac97, Qui03]
C’est donc plus ce dernier modèle, plus souple, que l’on va tenter d’exploiter
lorsqu’on développe des algorithmes parallèles fondés sur le parallélisme de données.

Application parallèle et réduction, avec vs. sans construction pour le


parallélisme de données
Nous avons déjàéjà vu, à la section 5.4, l’approche de parallélisme de données fondée
sur les applications et les réductions parallèles — le modèle avec les opérations Map
et Reduce.
Dans ce modèle, avec une opération style map, on part d’une grande quantité
de données à traiter et on applique en parallèle une fonction sur cette collection,
i.e., on part d’une collection (de données) pour produire une collection (résultat) :
def map_foo ( col )
col . pmap { | x | foo ( x ) }
end
Dans un langage qui ne supporte que le parallélisme de boucles, on peut quand
même réaliser un algorithme fondé sur le parallélisme de données :
def map_foo ( col )
res = Array . new ( col . size )

res . peach_index do | k |
res [ k ] = foo ( col [ k ] )
end

res
end

Parallélisme de résultat
Une stratégie intéressante et souvent utile dans le contexte du parallélisme de don-
née, stratégie proposée par Carriero & Gelernter [CG89], est celle du parallélisme
de résultat. Dans cette approche, on identifie le parallélisme en partant du produit
final, i.e., en partant du résultat désiré et en tentant de le décomposer en morceaux
indépendants. On attribue ensuite à chaque travailleur la tâche de produire un
morceau du résultat final.
Méthodologie de programmation parallèle 372

Remarque : Le parallélisme «de résultat» est une forme de parallélisme de données

• Si on indique simplement «parallélisme de données», c’est qu’on part des don-


nées pour identifier les tâches.

• Si on indique «parallélisme de résultat», c’est qu’on part du résultat à calculer


pour identifier les tâches.

Quelques exemples qui utilisent du parallélisme de données ou de résultat :

1. Programme Ruby 5.18 : Fait la somme de deux tableaux. Chaque position du


tableau à calculer représente une tâche indépendante.

2. Programme PRuby 5.15 : Fait le produit de deux matrices. Le calcul de chaque


élément de la matrice résultat (produit scalaire ligne/colonne) représente une
tâche indépendante.

Rappel : X
C[i, j] = A[i, k] × B[k, j]
k

Illustration des dépendances de calcul :

Parallélisme de données avec une ligne de A par thread


Méthodologie de programmation parallèle 373
Méthodologie de programmation parallèle 374

Parallélisme de données avec une ligne de A par thread


Méthodologie de programmation parallèle 375

Parallélisme de données avec une colonne de B par thread

Parallélisme de résultat avec un bloc de C par thread

Évidemment, dans chacun de ces cas, bien qu’à l’étape de partitionnement on


puisse obtenir une tâche par item du résultat, on peut ensuite produire un pro-
gramme plus performant en regroupant (agglomérant) ces tâches de granularité fine :

• Programme Ruby 5.18 : Utilisation d’un pmap avec «static:» (implicite).

• Programme Ruby 5.22 : Utilisation d’un pmap avec «dynamic:» explicite ou


d’un TaskBag.

• Programme PRuby 5.15 : Utilisation d’un peach avec «static:» implicite


pour les lignes, et d’un each pour les colonnes — donc agglomération par
blocs de lignes adjacents.
Méthodologie de programmation parallèle 376

On veut paralléliser la fonction histogramme présentée plus bas, fonction qui produit
un histogramme pour les entiers d’un tableau elems.

Chaque nombre d’elems est un entier compris entre 0 et val_max (incl.). Les bornes
du tableau résultant, e.g., histo, sont comprises entre 0 et val_max (incl.) tel que :
histo[val] = nombre d’occurrences de val dans elems

Exemple : soit les 12 valeurs et l’appel suivants :

elems == [10, 1, 3, 3, 3, 2, 9, 1, 1, 1, 3, 10]

histo = histogramme( elems, 10 )

Alors, après l’appel on aura :

histo == [0, 4, 1, 4, 0, 0, 0, 0, 0, 1, 2]

def histogramme_vide ( val_max )


Array . new ( val_max + 1) { 0 }
end

def histogramme ( elems , val_max )


histogramme = histogramme_vide ( val_max )

elems . each { | x | histogramme [ x ] += 1 }

histogramme
end

Exercice 7.1: Production d’un histogramme pour une série d’entiers bornés.

Décomposition géométrique
La motivation de ce patron est la suivante, patron aussi appelé décomposition du
domaine :

This pattern is used when (1) the concurrency is based on parallel updates
of chunks of a decomposed data structure, and (2) the update of each
chunk requires data from other chunks.
Source : http: // www. cise. ufl. edu/ research/ ParallelPatterns/
Méthodologie de programmation parallèle 377

PatternLanguage/ AlgorithmStructure/ GeoDecomp. htm


On verra un exemple dans un chapitre ultérieur — Chapitre 7.5.4, sur la diffusion
de la chaleur dans un cylindre.

Structures de données récursives


Ce patron est utilisé lorsque la structure de données à traiter est récursive, par
exemple, un arbre. Il s’agit d’une forme de parallélisme récursif, mais où la récursion
est guidée (déterminée) par la structure de données elle-même.
Par exemple, soit un arbre binaire complet pour lequel on veut faire la somme
des champs valeur, arbre ayant la structure suivante :
• Une Feuille comporte un champ valeur ;

• Un Noeud a toujours deux enfants, gauche et droite, mais n’a pas de champ
valeur.
On obtient alors facilement un algorithme parallèle récursif tel qu’illustré dans
le Programme Ruby 7.1.
Voici un petit exemple d’arbre avec deux noeuds internes et trois feuilles crées
et manipulés avec les méthodes du Programme Ruby 7.1 :
a = Noeud.new(
Noeud.new( Feuille.new(10),
Feuille.new(30) ),
Feuille.new(40)
)

puts a.inspect # Formate pour mieux illustrer la structure!


=>

#<Noeud:0x48b67364
@droite=#<Feuille:0x189cbd7c @valeur=40>,
@gauche=#<Noeud:0x7bf3a5d8
@droite=#<Feuille:0x42e25b0b @valeur=30>,
@gauche=#<Feuille:0x39b43d60 @valeur=10>
>
>

puts a.somme
=>
80
Méthodologie de programmation parallèle 378

Programme Ruby 7.1 Utilisation du parallélisme récursif pour parcourir un arbre


binaire et calculer la somme des valeurs des feuilles.
class Arbre
end

class Feuille < Arbre


...

def somme
@valeur
end
end

class Noeud < Arbre


attr_reader : gauche , : droite

...

def somme
fg = PRuby . future { gauche . somme }
fd = droite . somme

fg . value + fd
end
end
Méthodologie de programmation parallèle 379

7.5.3 Parallélisme de flux


Le parallélise de flux a été présenté à la section 5.6, donc nous ne le présenterons
pas en détail à nouveau.
Soulignons toutefois que certains auteurs parlent aussi dans ce cas de parallélisme
de type «producteur–consommateur » [And00] : le programme est composé d’un
ensemble de processus qui communiquent entre eux de façon uni-directionnelle, les
processus étant généralement organisés sous forme d’un pipeline — linéaire ou plus
complexe, e.g., un arbre ou un graphe dirigé acyclique (DAG), un réseau de tri par
exemple : voir figure 7.9.

Figure 7.9: Un réseau de tri avec un ensemble de processus de type producteur et


consommateur (source : [And00]).

D’autres auteurs présentent aussi ce patron sous le nom de parallélisme de


spécialistes [CG89, CG90] : on identifie diverses tâches spécialisées requises pour
effectuer le travail global. Ensuite, on associe à chaque travailleur une de ces tâches
spécifiques, en tentant le plus possible de les faire traiter en parallèle. Les divers
travailleurs exécutent donc des tâches tout à fait distinctes les unes des autres — pas
seulement en termes des données manipulées ou des attributs de la tâche, comme
dans un sac de tâches, mais même en termes du type de tâche effectué. Un exemple :
Méthodologie de programmation parallèle 380

les processus depaqueter, changer_exposant et paqueter dans le problème de


Jackson 5.6.4, qui font toutes des choses très différentes.

7.5.4 Arbre de décision pour choisir le patron le plus appro-


prié

Figure 7.10: Arbre de décision pour choisir le patron le plus approprié.

La figure 7.10 (tirée de [SMR09]) présente un «arbre de décision» pour déterminer


le patron d’algorithmes les plus approprié pour un problème. Encore une fois, il
s’agit d’une heuristique, pas d’un algorithme exact et précis.
Chapitre 8

Exemples illustrant l’approche


PCAM

8.1 Calcul de la distance d’édition entre deux chaines


8.1.1 Définition du problème
La distance d’édition est souvent utilisée pour définir une mesure de similarité
entre chaînes (de caractères, de symboles, de gènes, etc.). Différentes distances sur
diverses formes de chaînes ont ainsi été introduites, par ex., traitement et analyse
de textes, analyse de protéines et de génomes en bio-informatique, et même, tout
récemment, correction de dictées musicales.
L’idée maîtresse derrière la distance d’édition est de déterminer le nombre mini-
mum d’opérations (par ex., insertion, suppression, ou substitution) qui doivent être
appliquées sur la première chaîne pour obtenir la deuxième. Un exemple, présenté à
la Figure 8.1, montre comment le mot “surgery” peut être transformé en “survey”
à l’aide d’opérations de suppression, insertion ou substitution.

381
Approche PCAM et exemples de programmes 382

surgery
surery -- Suppression de g
surey -- Suppression de r
survey -- Insertion de v

surgery
urgery -- Suppression de s
rgery -- Suppression de u
gery -- Suppression de r
ery -- Suppression de g
very -- Insertion de v
vey -- Suppression de r
svey -- Insertion de s
suvey -- Insertion de u
survey -- Insertion de r

surgery
survery -- Substitution de g par v
survey -- Suppression de r

Figure 8.1: Trois façons différentes transformer de “surgery” en “survey”.


Approche PCAM et exemples de programmes 383

8.1.2 Une première solution purement récursive

Programme Ruby 8.1 Fonction récursive pour calculer la distance d’édition entre
deux chaines avec un cout unitaire pour les opérations.
def cout_subst ( c1 , c2 )
c1 == c2 ? 0 : 1
end

def distance ( ch1 , ch2 )


# Cas de base
return ch2 . size if ch1 . size == 0
return ch1 . size if ch2 . size == 0

# Cas recursifs
avec_insertion =
distance ( ch1 , ch2 [0.. -2] ) + 1

avec_suppression =
distance ( ch1 [0.. -2] , ch2 ) + 1

avec_substitution =
distance ( ch1 [0.. -2] , ch2 [0.. -2] ) +
cout_subst ( ch1 [ -1] , ch2 [ -1])

[ avec_insertion , avec_suppression , avec_substitution ]. min


end

La figure 8.2 présente l’arbre des appels récursifs pour un appel initial à la fonction
avec «distance( "ad", "axe" )».

Que constate-t-on quant aux appels récursifs qui sont effectués lors du calcul de la
distance d’édition entre "ad" et "axe"?
Exercice 8.1: Arbre des appels récursifs pour le calcul de distance d’édition.
Approche PCAM et exemples de programmes 384

distance( "ad", "axe" )


distance( "ad", "ax" )
distance( "ad", "a" )
distance( "ad", "" )
distance( "a", "a" )
distance( "a", "" )
distance( "", "a" )
distance( "", "" )
distance( "a", "" )
distance( "a", "ax" )
distance( "a", "a" )
distance( "a", "" )
distance( "", "a" )
distance( "", "" )
distance( "", "ax" )
distance( "", "a" )
distance( "a", "a" )
distance( "a", "" )
distance( "", "a" )
distance( "", "" )
distance( "a", "axe" )
distance( "a", "ax" )
distance( "a", "a" )
distance( "a", "" )
distance( "", "a" )
distance( "", "" )
distance( "", "ax" )
distance( "", "a" )
distance( "", "axe" )
distance( "", "ax" )
distance( "a", "ax" )
distance( "a", "a" )
distance( "a", "" )
distance( "", "a" )
distance( "", "" )
distance( "", "ax" )
distance( "", "a" )

Figure 8.2: Arbre des appels pour un appel initial à distance("ad", "axe").
Approche PCAM et exemples de programmes 385

distance( "ad", "axe" )


distance( "ad", "ax" )
distance( "ad", "a" )
distance( "ad", "" ) = 2
distance( "a", "a" )
distance( "a", "" ) = 1
distance( "", "a" ) = 1
distance( "", "" ) = 0
distance( "a", "a" ) => 0 # => Cas de base
distance( "a", "" ) = 1
distance( "ad", "a" ) => 1
distance( "a", "ax" )
distance( "a", "a" )
distance( "a", "" ) = 1
distance( "", "a" ) = 1
distance( "", "" ) = 0
distance( "a", "a" ) => 0
distance( "", "ax" ) = 2
distance( "", "a" ) = 1
distance( "a", "ax" ) => 1
distance( "a", "a" )
distance( "a", "" ) = 1
distance( "", "a" ) = 1
distance( "", "" ) = 0
distance( "a", "a" ) => 0
distance( "ad", "ax" ) => 1
distance( "a", "axe" )
distance( "a", "ax" )
distance( "a", "a" )
distance( "a", "" ) = 1
distance( "", "a" ) = 1
distance( "", "" ) = 0
distance( "a", "a" ) => 0
distance( "", "ax" ) = 2
distance( "", "a" ) = 1
distance( "a", "ax" ) => 1
distance( "", "axe" ) = 3
distance( "", "ax" ) = 2
distance( "a", "axe" ) => 2
distance( "a", "ax" )
distance( "a", "a" )
distance( "a", "" ) = 1
distance( "", "a" ) = 1
distance( "", "" ) = 0
distance( "a", "a" ) => 0
distance( "", "ax" ) = 2
distance( "", "a" ) = 1
distance( "a", "ax" ) => 1
distance( "ad", "axe" ) => 2

Figure 8.3: Arbre des appels pour un appel initial à distance("ad", "axe") avec
indication des résultats retournés — cas de base notés par «=», cas récursifs notés
par «=>».
Approche PCAM et exemples de programmes 386

8.1.3 Une formalisation du problème avec des équations de


récurrence
Soit deux chaînes ch1 et ch2. Dénotons par D(i, j) le coût minimal pour trans-
former la chaîne ch1[0...i] en ch2[0...j] (*), le coût étant défini par le nombre
d’opérations pour passer de l’une à l’autre tel que décrit plus bas. Les équations
de récurrences suivantes (équations récursives) donnent le coût pour passer de la
chaîne A à la chaîne B, en supposant que les opérations possibles sont l’insertion,
la suppression et la substitution.

D(0, 0) = 0
D(i, 0) = D(i − 1, 0) + coûtsup (ch1[i − 1])
D(0, j) = D(0, j − 1) + coûtins (ch2[j − 1])

 D(i − 1, j) + coûtsup (ch1[i − 1])
D(i, j) = min D(i, j − 1) + coûtins (ch2[j − 1])
D(i − 1, j − 1) + coûtsub (ch1[i − 1], ch2[j − 1])

distance(ch1, ch2) = D(ch1.size, ch2.size)


Pour simplifier, on va supposer que les coûts des diverses opérations sont définis
comme suit, c’est-à-dire que chaque opération a le même coût (coût unitaire) sont
les mêmes pour toutes les opérations :
Suppression coûtsup (c) = 1
Insertion coûtins (c) = 1
Substitution coûtsub (c, d) = (c == d ? 0 : 1)

(*) Note :

• "abc"[0...0] == ""
• "abc"[0...3] == "abc"
Approche PCAM et exemples de programmes 387

8.1.4 Une solution séquentielle avec «programmation dynamique»


basée sur les équations de récurrence
Stratégie de programmation dynamique :

= on précalcule — on «mémorise» — dans une table les résultats des divers


appels récursifs

= on effectue les divers calculs de façon ascendante — plutôt que descendante


comme dans un algorithme purement récursif

⇒ on procède des cas de base vers les cas plus complexes

Programme Ruby 8.2 Fonction séquentielle non-récursive utilisant la program-


mation dynamique pour calculer la distance d’édition entre deux chaines avec un
cout unitaire pour les opérations.
def distance ( ch1 , ch2 )
n1 = ch1 . size
n2 = ch2 . size
d = Matrice . new ( n1 +1 , n2 +1 )

# Cas de base ( couts unitaires ).


d [0 ,0] = 0
(1.. n1 ). each do | i |
d [i , 0] = i
end
(1.. n2 ). each do | j |
d [0 , j ] = j
end

# Cas recursifs .
((1.. n1 )*(1.. n2 )). each do |i , j |
d [i , j ] = [ d [i -1 , j ] + 1,
d [i , j -1] + 1,
d [i -1 , j -1] + cout_subst ( ch1 [ i ] , ch2 [ j ] )
]. min
end

d [ n1 , n2 ]
end
Approche PCAM et exemples de programmes 388

a x e
_ _ _ _
| 0 1 2 3
a | 1 1 2 3
d | 2 2 2 2

Figure 8.4: Contenu de la matrice d à la fin de la méthode distance pour ch1 =


"ad" et ch2 = "axe".

s u r v e y
_ _ _ _ _ _ _
| 0 1 2 3 4 5 6
s | 1 0 1 2 3 4 5
u | 2 1 0 1 2 3 4
r | 3 2 1 1 2 3 4
g | 4 3 2 2 1 2 3
e | 5 4 3 3 2 2 3
r | 6 5 4 4 3 2 3
y | 7 6 5 5 4 3 2

Figure 8.5: Contenu de la matrice d à la fin de la méthode distance pour ch1 =


"surgery" et ch2 = "survey".
Approche PCAM et exemples de programmes 389

8.1.5 Analyse des dépendances pour une version parallèle de


la solution avec «programmation dynamique»

1. Quelles sont les tâches les plus fines de la version séquentielle?

2. Quelles sont les dépendances entre ces tâches?

Exercice 8.2: Tâches et dépendances

Parallélisme de résultat : Chaque entrée de la matrice peut être vue comme une
tâche indépendante
Approche PCAM et exemples de programmes 390

Parallélisme de résultat : Si on ignore la première ligne et la première colonne,


la position [i,j] dépend des positions [i-1,j], [i,j-1] et [i-1,j-1]
Approche PCAM et exemples de programmes 391

Parallélisme de résultat : Si on suppose que la première ligne et la première


colonne sont évaluées au temps 0, on peut alors numéroter comme suit le moment
où les éléments pourront être évalués :
Approche PCAM et exemples de programmes 392

On appelle un tel patron d’évaluation un calcul wavefront : les éléments sur


une même ligne de front peuvent être évalués en parallèle

Programme Ruby 8.3 Fonction parallèle non-récursive utilisant la programma-


tion dynamique pour calculer la distance d’édition entre deux chaines avec un cout
unitaire pour les opérations.

Programme omis
Approche PCAM et exemples de programmes 393

8.2 Résolution numérique de l’équation de diffusion


de la chaleur dans un cylindre
8.2.1 Introduction
De nombreux phénomènes physiques sont modélisés par des équations différen-
tielles : diffusion de la chaleur, propagation d’ondes (sonores, sismiques, magné-
tiques), déplacement d’un pendule, radioactivité, évolution de populations animales,
etc.
Certaines de ces équations peuvent être résolues de façon analytique (symbol-
ique). Par contre, de nombreuses équations ne peuvent pas être résolues de cette
façon. Dans ce dernier cas, on peut alors utiliser des approximations numériques
des solutions recherchées.
C’est ce que nous verrons dans ce document avec un exemple concret simple
portant sur la diffusion de la chaleur dans un cylindre métallique.
Nous verrons aussi comment obtenir un programme parallèle produisant cette
solution numérique, et ce en utilisant la stratégie PCAM de Foster [Fos95].
Ces notions de base nous permettront ensuite de comprendre les différentes so-
lutions MPI/C (vues en cours) pour la résolution de ce problème simple, solutions
tirées du livre de Mattson, Sanders et Massingill [MSM05].

8.2.2 Le problème et sa solution


Description du problème
Soit un cylindre métallique de longueur l,1 Le cylindre est initialement à une tem-
pérature t. Les extrémités gauche et droite du cylindre sont maintenues à une
température constante à l’aide de sources de chaleur et de thermostats. Les tem-
pératures aux extrémités sont respectivement tgauche à gauche et tdroite à droite.
Quant à la température pour le reste du cylindre, on suppose qu’elle est la même
partout, soit une température de tinitial . On suppose que le cylindre est isolé ther-
miquement sur toute sa longueur, donc on peut ignorer les pertes de chaleur. Voir
figure 8.6 pour une représentation du cylindre.
On veut déterminer de quelle façon sera distribuée la température le long du
cylindre lorsque le point d’équilibre sera atteint, donc comment la température sera
diffusée des extrémités vers l’ensemble du cylindre, en tenant compte du gradient
de température (allant de tgauche à gauche jusqu’à tdroite à droite).
1
La longueur exacte n’a aucune importance, puisqu’on peut toujours faire une mise à l’échelle.
En fait, pour simplifier, on pourrait supposer une longueur unitaire, c’est-à-dire égale à 1.0.
Approche PCAM et exemples de programmes 394

Figure 8.6: Cylindre à l’état initial.

Description générale de la solution : discrétisation de l’espace et du temps

Figure 8.7: Cylindre à l’état initial mais discrétisé — découpé en petits segments.

Pour déterminer la distribution de la température le long du cylindre, on va


simuler numériquement l’évolution de la température en fonction du temps. Plus
spécifiquement, puisqu’on travaille avec un ordinateur et qu’on doit simuler un
espace continu (le cylindre) que le temps continu, donc on va discrétiser l’espace
et le temps. En d’autres mots, on va découper le cylindre en petits segments
(discrétisation de l’espace) puis on va, de façon itérative, simuler l’avancement du
temps (discrétisation du temps) en simulant la propagation de la chaleur entre les
segments adjacents durant un «petit intervalle de temps». Idéalement, on va répéter
Approche PCAM et exemples de programmes 395

ce processus itératif jusqu’à ce qu’on atteigne un «point fixe», c’est-à-dire un «état


stable» où il n’y a plus aucun changement.

Représentation numérique du cylindre et de la température


La seule propriéte du cylindre qui nous intéresse est sa température. Puisqu’on le
découpe en petits segments adjacents, l’état de la température du cylindre peut donc
être représenté par un simple vecteur de nombres réels, en supposant que chaque
tranche/segment de cylindre est à température uniforme.
Pour simuler la diffusion de la chaleur entre segments, on va supposer ce qui suit,
qui se veut une approximation discrète d’un processus continu :
• Durant un (court) intervalle de temps, la chaleur se propage uniquement entre
deux (petits) segments adjacents ;
• Soit un petit segment s qui n’est pas à une des extrémités. Alors le segment
à gauche de s a la même influence sur s que le segment à droite.
• L’influence de la température d’un voisin est proportionnelle au gradient de
température, i.e., à la différence de température.
Supposons donc qu’on découpe (discrétise) le cylindre en n segments. Soit alors
T un vecteur de taille n (indexé de 0 à n−1) qui représente la température de chacun
des petits segments. Plus précisément, soit Tt [i] la température du ième segment au
temps t — où le segment i n’est pas à l’une des extrémités. On va alors chercher
à déterminer la température de ce même segment, mais cette fois au temps t + 1.
Cette température va dépendre de trois facteurs :
• De la température du segment lui-même, i.e., Tt [i].
• De l’influence du segment à gauche, donc du gradient de température associé
au voisin gauche, représenté par la différence suivante : Tt [i − 1] − Tt [i].
• De l’influence du segment à droite : Tt [i + 1] − Tt [i].
En se fondant sur l’hypothèse mentionnée précédemment (gauche et droite ont
la même influence), on obtient alors l’équation suivante :

(Tt [i − 1] − Tt [i]) + (Tt [i + 1] − Tt [i])


Tt+1 [i] = Tt [i] +
2

Tt [i − 1] + Tt [i + 1]
=
2
Approche PCAM et exemples de programmes 396

En d’autres mots, en se fondant sur ce modèle, la température au temps t + 1


est simplement la moyenne des températures des voisins adjacents! C’est ce qu’on
appelle l’équation de Jacobi.

8.2.3 Un exemple concret


Soit un cylindre de longueur l = 6 avec tgauche = 1◦ C, tdroite = 10◦ C et tinitial = 0◦ C.
On va simuler la distribution de température en décomposant le cylindre en six
segments de longueur unitaire. On aura alors l’évolution suivante de la distribution
de température, où T0 représente l’état initial et où les calculs sont faits avec deux
chiffres de précision après le point décimal.
T0 = 1.00 0.00 0.00 0.00 0.00 10.00

T1 = 1.00 0.50 0.00 0.00 5.00 10.00

T2 = 1.00 0.50 0.25 2.50 5.00 10.00

T3 = 1.00 0.63 1.50 2.63 6.25 10.00

T4 = 1.00 1.25 1.63 3.88 6.32 10.00


.
.
.
T30 = 1.00 2.74 4.59 6.39 8.19 10.00

T31 = 1.00 2.80 4.60 6.40 8.20 10.00

T32 = 1.00 2.80 4.60 6.40 8.20 10.00

T33 = 1.00 2.80 4.60 6.40 8.20 10.00


.
.
.
On remarque donc qu’à partir de T31 , on atteint un point fixe. En d’autres mots,
la distribution de température devient stable, ne change plus du tout par la suite.
C’est donc qu’on a convergé vers la solution finale.
Approche PCAM et exemples de programmes 397

Quelques remarques
Effet de l’augmentation du nombre de points
• Plus le nombre de points utilisés (le nombre de segments utilisés pour la dis-
crétisation de l’espace) est grand — mais en autant qu’on reste dans les limites
de précision de calcul — alors plus la solution numérique finale est précise.
Toutefois, le temps pour arriver à un point fixe est alors plus long.
Ainsi, dans l’exemple précédent, supposons qu’on utilise 60 segments (au lieu de
6), toujours pour une précision de deux chiffres après le point. Alors :
T0 = 1.00 0.00 0.00 0.00 . . . 0.00 0.00 0.00 10.00
.
.
.
T33 = 1.00 0.86 0.73 0.61 . . . 6.08 7.28 8.60 10.00
Quant au point fixe, il ne serait atteint qu’après plusieurs centaines d’itérations.

8.2.4 Application de l’approche PCAM de Foster


Nous allons maintenant illustrer l’approche PCAM de Foster sur le problème du
cylindre, et ce en utilisant la méthode de Jacobi (première méthode la plus simple).

Partitionnement
Pour un point donné (discrétisation spatiale du cylindre), il va falloir calculer sa
température au temps (discrétisation temporelle) t = 0, t = 1, t = 2, etc.
Notons par Tt [i] la valeur du point T [i] au temps t. Supposons que comme dans
notre exemple précédent, nous ayons aussi 6 points (i = 0, . . . , 5) et que nous voulons
calculer pour t = 0, 1, 2, . . . , 9.
Il nous faudra alors calculer les différentes valeurs illustrées à la figure 8.8, qui
représentent donc les différentes tâches de granularité les plus fines.

Communications
Il nous faut ensuite identifier les dépendances entre les différentes valeurs (tâches)
pour pouvoir identifier les communications potentielles.
L’équation de Jacobi est la suivante :
Tt [i − 1] + Tt [i + 1]
Tt+1 [i] =
2
Les dépendances sont donc celles illustrées dans la figure 8.9.
Approche PCAM et exemples de programmes 398

Figure 8.8: Les différentes tâches de granularité fine.


Approche PCAM et exemples de programmes 399

Figure 8.9: Dépendances des tâches de granularité fine.


Approche PCAM et exemples de programmes 400

Agglomération
À un premier niveau, on peut agglomérer les tâches de façon verticale, donc en
combinant ensemble les tâches responsables de calculer, pour un point donné, les
différentes valeurs dans le temps. En d’autres mots, on effectue alors une aggloméra-
tion selon la dimension temporelle. On obtient alors les dépendances illustrées à la
figure 8.10.

Figure 8.10: Dépendances des tâches lorsqu’une tâche est pour un seul et unique
point (segment) — agglomération temporelle.

À un deuxième niveau, on peut ensuite agglomérer les tâches de façon horizon-


tale, donc en combinant les tâches responsables de différents points adjacents. En
d’autres mots, on effectue alors une agglomération selon la dimension spatiale. Si
on suppose qu’on cible une machine avec trois processeurs, et qu’on veut obtenir
trois processus, on obtiendrait alors les dépendances illustrées à la figure 8.11. Sig-
nalons que seules les flèches plus larges/épaisses représentent des dépendances qui
donneront lieu à des communications inter-processus.
Approche PCAM et exemples de programmes 401

Figure 8.11: Dépendances des tâches lorsqu’une tâche est pour un groupe de points
(segments) — agglomération spatiale.
Approche PCAM et exemples de programmes 402

Mapping
Puisque nous allons programmer cette solution en MPI/C (solution de Mattson,
Sanders et Massingill [MSM05]), nous allons donc utiliser une association statique
entre tâches et processus, typique des programmes SPMD (Single Program, Multiple
Data) qu’on retrouve en MPI.

8.2.5 Le code MPI/C de Mattson, Sanders et Massingil


Différentes versions du code C et MPI/C présentées par Mattson, Sanders et Massingil [MSM05] :

• Version séquentielle :
http://www.labunix.uqam.ca/~tremblay/INF7235/Materiel/heat-seq.c

• Version parallèle avec envois synchrones :


http://www.labunix.uqam.ca/~tremblay/INF7235/Materiel/heat-par.c

• Version parallèle avec envois asynchrones :


http://www.labunix.uqam.ca/~tremblay/INF7235/Materiel/heat-par-asyn.
c

8.2.6 Accélération de la convergence


L’équation utilisée plus haut pour le calcul est celle dite de Jacobi :

Tt [i − 1] + Tt [i + 1]
Tt+1 [i] =
2
Or, lorsqu’on calcule de façon séquentielle, de gauche à droite, les différentes
valeurs, on peut alors accélérer la convergence en utilisant la nouvelle valeur du
voisin gauche plutôt que l’ancienne.
Dans ce cas, on utilise alors l’équation suivante dite de Gauss–Seidel :

Tt+1 [i − 1] + Tt [i + 1]
Tt+1 [i] =
2
Pour notre exemple précédent (six points), on aurait alors les itérations suiv-
antes :
Approche PCAM et exemples de programmes 403

T0 = 1.00 0.00 0.00 0.00 0.00 10.00

T1 = 1.00 0.50 0.25 0.13 5.06 10.00

T2 = 1.00 0.63 0.38 2.72 6.36 10.00

T3 = 1.00 0.69 1.70 4.03 7.02 10.00


.
.
.

T15 = 1.00 2.80 4.60 6.40 8.20 10.00

T16 = 1.00 2.80 4.60 6.40 8.20 10.00

T17 = 1.00 2.80 4.60 6.40 8.20 10.00


.
.
.

Parallélisation de la méthode de Gauss–Seidel


Le désavantage de la méthode de Gauss-Seidel est qu’elle n’est pas parallélisable à
cause de la dépendence séquentielle créée par le balayage des points de la gauche
vers la droite.
Tt+1 [i − 1] + Tt [i + 1]
Tt+1 [i] =
2
Mais, Il est quand même possible de paralléliser cette méthode, pour profiter de la
convergence plus rapide, en effectuant l’ensemble des calculs en deux (2) passes.
Plus précisément, on divise l’ensemble des points en deux sous-groupes, typique-
ment appelés les points rouges (indices pairs) vs. les points noirs (indices impairs).
Pour le calcul à un temps t donné, on calcule tout d’abord l’ensemble des valeurs
pour les points rouges — qui ne dépendent que des points noirs. Ensuite, on ef-
fectue le calcul pour les points noirs — qui ne dépendent que des points rouges,
déjà calculés donc de temps t + 1.
Dans notre exemple précédent avec six points, on aurait alors les valeurs suivantes
pour la première itération :
Approche PCAM et exemples de programmes 404

R N R N R N
T0 = 1.00 0.00 0.00 0.00 0.00 10.00

T1R = 1.00 0.00 5.00

T1N = 0.50 2.50 10.00

T1 = 1.00 0.50 0.00 2.50 5.00 10.00

T2R = 1.00 1.50 6.25

T2N = 1.25 3.88 10.00

T2 = 1.00 1.25 1.50 3.88 6.25 10.00


Partie V

Programmation parallèle en mémoire


partagée avec d’autres langages

405
Chapitre 9

Programmation parallèle avec C++


et les Threading Building Blocks
d’Intel

9.1 Introduction
Ce document présente un aperçu des Threading Building Blocks (TBB) d’Intel. La
figure 9.1 présente une vue d’ensemble des différents éléments de TBB [Rob09].
Dans le présent document, nous allons nous concentrer plus spécifiquement sur les
algorithmes parallèles génériques.

• La notion fondamentale qui sous-tend l’approche TBB est celle de «tâche» et


non celle de «thread » : On conçoit un programme en termes de tâches (tâches
logiques, possiblement lègères et à fine granularité) et on délègue au système
(à la mise en oeuvre de la bibliothèque) la responsabilité d’affecter ces tâches à
des threads (physiques ou virtuelles) et d’ordonnancer l’exécution des threads
— donc dans le style du «guided scheduling» d’OpenMP.
En d’autres mots, on expose (on exprime) autant de parallélisme que pos-
sible, et on laisse TBB «choose how much of that parallelism is actually ex-
ploited » [Rei07].

• TBB met l’accent sur une approche de parallélisme de données, tout en perme-
ttant quand même les approches de parallélisme de contrôle et de parallélisme
de flux (pipelines).

• TBB, même pour le parallélisme itératif — parallélisme de boucles avec paral-


lel_for —, repose sur une approche de décomposition récursive diviser-pour-

406
TBB 407

Figure 9.1: Les principaux composants de TBB selon Robison [Rob09].

Figure 9.2: Historique de TBB (selon Intel).


TBB 408

régner dichotomique, mais sans toutefois utiliser nécessairement du paral-


lélisme récursif.

• TTB s’inspire de l’approche proposée par Cilk [FLR98, MRR12] pour l’ordon-
nancement des tâches — appelé «task stealing» :
– Permet de réduire les surcoûts liés à la création et à l’ordonnancement
des tâches/threads ;
– Assure une utilisation efficace des caches (meilleure localité).

• TBB est une bibliothèque en C++donc :


– Ne requiert aucun compilateur spécial ;
– Fonctionne sur (à peu près) n’importe quel processeur ou système d’exploitation
ayant un compilateur C++.

• TBB utilise des templates C++ pour exprimer et réaliser les principaux pa-
trons de programmation parallèle — parallélisme de boucles, de flux (pipeline),
récursif (diviser-pour-régner), etc.

• Quelques références : Voir notes de cours

– Un livre dédié exclusivement aux TBB [Rei07] : «Intel Threading Building


Blocks: Outfitting C++ for Multi-Core Processor Parallelism», J. Rein-
ders, O’Reilly Media, 2007.
– Un livre qui traite de différents langages, dont les TBB [MRR12] : «Struc-
tured Parallel Programming—Patterns for Efficient Computation», M. Mc-
Cool, A.D. Robison et J. Reinders, Morgan Kaufmann, 2012.
– Une présentation de TBB qui introduit aussi les éléments de base de C++
requis pour comprendre les TBB : «Intel Threading Building Blocks»,
A.D. Robison, 2009.
– Le site Web suivant pour la documentation des classes et méthodes de
TBB :
∗ http://www.threadingbuildingblocks.org/docs/doxygen/
TBB 409

9.2 Quelques éléments de C++


Pour bien comprendre les Threading Building Blocks, il est important de comprendre
certains éléments de C++, dont certains éléments du plus récent standard C++11,
notamment les templates et les λ-expressions, mais aussi le passage de paramètre
par référence.

9.2.0 Passage de paramètres par valeur vs. par référence


Call by reference (also referred to as pass by reference) is an evaluation
strategy where a function receives an implicit reference to a variable used
as argument, rather than a copy of its value. This typically means that
the function can modify (i.e. assign to) the variable used as argument—
something that will be seen by its caller.

https: // en. wikipedia. org/ wiki/ Evaluation_ strategy

1. Pour chacun des programmes C++ ci-bas, indiquez ce qui sera affiché si on
compile (avec g++) puis on exécute.

Dans tous les cas, on suppose qu’un #include <cstdio> est présent au début
du fichier.

2. Pour le programme pgm5.cpp, est-ce que les deux versions de la fonction


incinc produisent toujours le même effet?

3. En FORTRAN, tous les paramètres sont toujours passés par référence.


Qu’est-ce qui était imprimé en FORTRAN-77 par le programme suivant — dans
l’appel à PRINT, «*» dénote la sortie standard (stdout) :
FUNCTION inc ( x )
x = x + 1
END

inc ( 0 )

PRINT * , " 0 = " , 0

Exercice 9.0: Passage par valeur vs. par référence.


TBB 410

pgm1.cpp
void inc ( int x ) { x += 1; }

void inc ( int * x ) { * x += 1; }

int main ( int argc , char * argv [] )


{
int x = 0;

inc ( x ); printf ( " % d \ n " , x );

inc ( & x ); printf ( " % d \ n " , x );


}

pgm2.cpp
void inc ( int & x ) { x += 1; }

void inc ( int * x ) { * x += 1; }

int main ( int argc , char * argv [] )


{
int x = 0;

inc ( x ); printf ( " % d \ n " , x );

inc ( & x ); printf ( " % d \ n " , x );


}

pgm3.cpp
void inc ( int x ) { x += 1; }

void inc ( int & x ) { x += 1; }

int main ( int argc , char * argv [] )


{
int x = 0;

inc ( x ); printf ( " % d \ n " , x );

inc ( x ); printf ( " % d \ n " , x );


}
TBB 411

pgm4.cpp
void inc ( int & x ) { x += 1; }

int main ( int argc , char * argv [] )


{
int x = 0;

inc ( 1 ); printf ( " % d \ n " , x );


}

pgm5.cpp
void incinc ( int * x_ , int * y_ , int n ) {
int x = * x_ , y = * y_ ; // Copy - in (= > copies locales ).
for ( int i = 0; i < n ; i ++ ) {
x ++; y ++; // Traitement sur copies locales .
}
* x_ = x ; * y_ = y ; // Copy - out (= > met a jour arguments ).
}

void incinc ( int & x , int & y , int n ) {


for ( int i = 0; i < n ; i ++ ) {
x ++; y ++;
}
}

int main ( int argc , char * argv [] )


{
int x , y ;

x = y = 0;
incinc ( &x , &y , 10 ); printf ( " % d % d \ n " , x , y );
x = y = 0;
incinc ( x , y , 10 ); printf ( " % d % d \ n " , x , y );
}
TBB 412

9.2.1 Les templates génériques

Programme C++ 9.1 Une procédure générique pour echanger le contenu de deux
variables.
template < typename T >
void echanger ( T & x , T & y ) {
T tmp = x ;
x = y;
y = tmp ;
}

// Exemples d ’ utilisation : Avec types explicites .


int x , y ;
...
echanger < int >( x , y ); // void echanger ( int & , int & );

float a , b ;
...
echanger < float >( a , b ); // void echanger ( float & , float & );

// Exemples d ’ utilisation : Avec inference de types


// par le compilateur .

// void echanger ( int & , int & );


// Donc meme instance que plus haut !
echanger ( x , y );

// void echanger ( float & , float & );


echanger ( a , b );

Les templates permettent de définir des algorithmes, opérations ou types géné-


riques, c’est-à-dire, pouvant (notamment) être paramétrisés par des types.
Le programme C++ 9.1 présente un exemple d’une procédure générique qui per-
met d’échanger le contenu de deux variables d’un même type T, un type arbitraire.
Un point important à souligner : le compilateur peut se charger d’inférer le type
approprié et d’instancier et d’utiliser la méthode appropriée — une utilisation avec
un type distinct va produire une instance distincte de la procédure.
TBB 413

Programme C++ 9.2 Une utilisation incorrecte de echanger. . .


template < typename T >
void echanger ( T & x , T & y ) {
T tmp = x ;
x = y;
y = tmp ;
}

// Autre exemple d’utilisation avec types implicites.


int x = 1;
float a = 2.0;
...
echanger ( x , a );

...

$ /usr/bin/g++ -m64 -ltbb -std=c++11 echanger.cpp -o echanger


TBB 414

Programme C++ 9.3 Une utilisation incorrecte de echanger. . .


template < typename T >
void echanger ( T & x , T & y ) {
T tmp = x ;
x = y;
y = tmp ;
}

// Autre exemple d’utilisation avec types implicites.


int x = 1;
float a = 2.0;
...
echanger ( x , a );

...

$ /usr/bin/g++ -m64 -ltbb -std=c++11 echanger.cpp -o echanger


echanger.cpp: In function ’int main(int, char**)’:
echanger.cpp:32:18: erreur: no matching function for call to ’echanger(int&, float&)’
echanger( x, a );
^
echanger.cpp:32:18: note: candidate is:
echanger.cpp:4:6: note: template<class T> void echanger(T&, T&)
void echanger( T& x, T& y ) {
^
echanger.cpp:4:6: note: template argument deduction/substitution failed:
echanger.cpp:32:18: note: deduced conflicting types
for parameter ’T’ (’int’ and ’float’)
echanger( x, a );
^
TBB 415

Que fait le programme ci-bas?


# include < string >

template < int N >


struct Foo
{
enum { val = N * Foo <N -1 >:: val };
};

template <>
struct Foo <0 >
{
enum { val = 1 };
};

int main ( int argc , char * argv [] )


{
const int N = 10;
printf ( " % d = > % d \ n " , N , Foo <N >:: val );
return 0;
}

------------------------------------------

$ / usr / bin / g ++ - m64 - ltbb - std = c ++11 foo . cpp -o foo


$ foo
10 = > 3628800

Exercice 9.1: Une utilisation non-triviale, et quelque peu étonnante, des templates.
TBB 416

9.2.2 Les lambda-expressions


En C++, un objet-fonction (function object) — appelé aussi un foncteur (a func-
tor ) — est simplement un objet possédant une méthode «operator()».
Une telle méthode peut prendre 0, 1 ou plusieurs arguments.
Une λ-expression — on dit «lambda-expression» — est une expression qui génère
un foncteur anonyme. Puisqu’il s’agit d’un foncteur, la méthode «operator()» est
donc disponible.
Comme son nom l’indique, une λ-expression est une expression, donc peut/doit
être utilisée dans un contexte nécessitant une expression.
La syntaxe générale d’une λ-expression est la suivante, donc ressemble à une
définition de fonction mais avec une liste additionnelle d’éléments de capture et sans
nom de fonction (fonction anonyme) :
[capture ]( parametres_formels ) -> type_du_resultat {
corps
}

• capture : cette partie, optionnelle, spécifie les variables non-locales utilisées


dans le corps de la λ-expression qui doivent être capturées. On peut spécifier
explicitement les variables à capturer — par exemple «[x, &r]», donc unique-
ment les noms de variables, sans les types — ou implicitement en spécifiant
que toutes les variables non-locales doivent être capturées et conservées, soit
par valeur (=), soit par référence (&) :

– [=] : Par valeur


– [&] : Par référence

Lorsqu’aucune variable n’a à être capturée, on utilise «[]».

• ( parametres_formels ) : peut être omis s’il n’y a pas de paramètre

• -> type_du_resultat : peut être omis si le type du résultat est void ou si une
unique instruction «return expr;» est explicitement présente dans le corps
de la λ-expression.

Le programme C++ 9.4 présente divers exemples de λ-expressions. Quant au


dernier exemple, il illustre plus spécifiquement l’utilisation du mot-clé auto pour
indiquer un type de façon implicite, type déterminé par le compilateur : :
auto plusX = [ x ]( int y ){ return x + y ; };
TBB 417

Programme C++ 9.4 Exemples de définition et d’utilisation de λ-expressions.


// Lambda -expressions sans argument.
x = 9;
r = [ x ]{ return x + 1; }();
// x == 9 && r == 10

x = 9;
r = [=]{ return x + 1; }();
// x == 9 && r == 10

x = 9;
r = [& x ]{ return x ++; }();
// x == 10 && r == 9

x = 9;
r = [&]{ return x ++; }();
// x == 10 && r == 9

Il faut noter que puisqu’une λ-expression génère un type anonyme, connu unique-
ment du compilateur, le type auto est la seule façon de déclarer et définir explicite-
ment une variable associée à une λ-expression.

// Lambda -expressions avec argument.


x = 9;
r = [=]( int y ){ return x + y ; }( 25 );
// x == 9 && r == 34

x = 9;
r = [&]( int y ){ x += 2; return x + y ; }( 25 );
// x == 11 && r == 36

x = 9;
r = 0;
[x , & r ]( int y ){ r = x + y ; }( 25 );
// x == 9 && r == 34
TBB 418

r = [=]( int y ){ x += 2; return x + y ; }( 25 );

/*
lambdas.cpp: In lambda function:
lambdas.cpp:11:23: erreur:
assignment of read -only variable ’x’
r = [=]( int y ){ x += 2; return x + y; }( 100 );
*/

// Si on a indique #include <functional >


x = 11;
std :: function < int ( int ) > plusX
= [ x ]( int y ){ return x + y ; };

assert ( plusX (100) == 111 );

// On peut aussi utiliser auto comme type.


x = 9;
auto plusXbis = [ x ]( int y ){ return x + y ; };

assert ( plusXbis (12) == 21 );


TBB 419

Pour chacun des segments de code C++ ci-bas, indiquez ce qui sera affiché si on
compile (avec g++) puis on exécute.

1. Capture par référence d’un tableau statique :


int a [ n ];
auto init_zero = [&]( int i ) { a [ i ] = 0; };
init_zero (0);
printf ( " % d \ n " , a [0] );

2. Capture par valeur d’un tableau statique :


int a [ n ];
auto init_zero = [=]( int i ) { a [ i ] = 0; };
init_zero (0);
printf ( " % d \ n " , a [0] );

3. Capture par valeur d’un tableau passé en argument :


void init_zero ( int a [] , int i ) {
[=]( int i ){ a [ i ] = 0; }( i );
}

int a [ n ];
init_zero (a , 0);
printf ( " % d \ n " , a [0] );

4. Capture par valeur d’un tableau dynamique :


int * a = ( int *) malloc ( n * sizeof ( int ));
auto init_zero = [=]( int i ) { a [ i ] = 0; };
init_zero (0);
printf ( " % d \ n " , a [0] );

Exercice 9.4: Capture par valeur vs. par référence.


TBB 420

9.2.3 Structures de contrôle définies par le programmeur avec


λ-expressions
Les templates génériques et les lambda-expressions (et foncteurs) peuvent être util-
isés pour, notamment, définir de nouvelles structures de contrôle — donc des
structures de contrôles définies par le programmeur.

Programme C++ 9.5 Une structure de contrôle ForEach (itération séquentielle),


utilisée avec des λ-expressions.
template < typename Functor >
void ForEach ( int inf , int sup , Functor f ) {
for ( int i = inf ; i < sup ; i ++ ) {
f ( i );
}
}

// Exemples d ’ utilisation .
int a [4] = {10 , 20 , 93 , 12};

// Capture de a , par reference pcq . a [ i ] est modifie .


ForEach ( 0 , 4 , [&]( int i ){ a [ i ] += 1; } );
// a = {11 , 21 , 94 , 13}

// Captures : a par valeur , le_max par reference .


le_max = 0;
ForEach ( 0 , 4 ,
[a , & le_max ]( int i ) {
le_max = a [ i ] > le_max ? a [ i ] : le_max ;
}
);
// le_max == 94

Le programme C++ 9.5 présente un exemple d’une structure de contrôle ForEach


ainsi que deux utilisations de cette structure de contrôle, avec des λ-expressions.
TBB 421

9.3 Parallélisme de boucles : parallel_for


Dans cette section, nous examinons comment TBB supporte le parallélisme de
boucles à l’aide de la construction parallel_for.

9.3.1 La spécification du parallel_for


La méthode générique pour le parallélisme de boucle est parallel_for, dont les
signatures possibles (simplifiées : voir Section 9.5) sont ls suivantes :

template < typename Index , typename Func >


Func parallel_for ( Index first ,
Index_type last ,
const Func & f );

template < typename Index , typename Func >


Func parallel_for ( Index first ,
Index_type last ,
Index step ,
const Func & f );

template < typename Range , typename Body >


void parallel_for ( const Range & range ,
const Body & body )
// Parallel iteration over Range with default
// partitioner .

• L’algorithme 9.1 décrit, de façon informelle, l’effet d’exécuter un parallel_for


du premier type – avec bornes inférieure et supérieure et une lamba-expression
Func.
• L’algorithme 9.2 décrit, de façon informelle, l’effet d’exécuter un parallel_for
du troisième type – avec un foncteur qui manipule un Range.
• Le Programme TBB 9.1 donne la spécification d’un objet de type Range.
• Le Programme TBB 9.2 donne la spécification d’un objet de type Body. Un
tel objet est donc un function object, mais spécialisé — un foncteur avec un
argument spécifique de type Range.
TBB 422

PROCEDURE parallel_for( first: Index,


last: Index,
func: Func )
DEBUT
EN PARALLELE POUR i ← first A last-1 FAIRE
func(i)
FIN
FIN

Algorithme 9.1: Description informelle de l’effet d’un parallel_for avec des


index.

PROCEDURE parallel_for( r: Range,


body: Body )
DEBUT
sr ← decomposerEnSousIntervallesDisjoints (r)
k ← sr.size()
ASSERT: ∀i,j∈{1..k} • i 6= j ⇒ sr[i] ∩ sr[j] = {}
[
ASSERT: sr[i] = r
1≤i≤k

EN PARALLELE POUR i ← 1 A k FAIRE


body(sr[i])
FIN
FIN

Algorithme 9.2: Description informelle — et conceptuelle — de l’effet d’un


parallel_for avec un Range.
TBB 423

Exemple «à la Ruby» pour le Range et sa décompositon en sous-Range :

• Soit l’appel suivant :


sr = decomposerEnSousIntervallesDisjoints(0...100)

• Divers résultats possibles pour sr :

sr = [0...50, 50...100]

sr = [0...20, 20...40, 40...60, 60...80, 80...100]

sr = [0...10, 10...20, 20...30, . . . , 80...90, 90...100]

sr = [0...1, 1...2, 2...3, . . . , 97...98, 98...99, 99...100]

sr = [0...20, 20...60, 60...70, 70...100]

Programme TBB 9.1 Spécification d’un Range — utilisé dans le template du


parallel_for.
// Class R implementing the concept of range
// must define :

R :: R ( const R & );
// Copy constructor

R ::~ R ();
// Destructor

bool R :: is_divisible () const ;


// True if range can be partitioned into two subranges

bool R :: empty () const ;


// True if range is empty

R :: R ( R & r , split );
// Split range r into two subranges .
TBB 424

Programme TBB 9.2 Spécification d’un Body — utilisé dans le template du


parallel_for.
// Class Body implementing the concept
// of parallel_for body must define :

Body :: Body ( const Body & );


// Copy constructor

Body ::~ Body ();


// Destructor

void Body :: operator ()( Range & r ) const ;


// Function call operator applying
// the body to range r .

template < typename Range , typename Body >


void parallel_for ( const Range & range ,
const Body & body )
// Parallel iteration over range
// with default partitioner .
TBB 425

9.3.2 Exemples : Les deux formes de parallel_for


Le programme TBB 9.3 présente une utilisation de la première et de la troisième forme
de parallel_for, dans les deux cas pour simplement incrémenter les éléments d’un
tableau — donc semblable à l’exemple précédent (programme C++ 9.5).

Programme TBB 9.3 Deux exemples simples d’utilisation d’un parallel_for.


// Premiere forme : avec bornes inferieure et superieure .
int a [4] = {10 , 20 , 93 , 12};
parallel_for (
0, 4,
[&]( int i ){ a [ i ] += 1; }
);

// a == {11 , 21 , 94 , 13}

// Deuxieme forme : avec blocked_range .


int a [4] = {10 , 20 , 93 , 12};
parallel_for (
blocked_range < size_t >(0 , 4) ,
[&]( blocked_range < size_t > r ){
for ( int i = r . begin (); i < r . end (); i ++ ){
a [ i ] += 1;
}
}
);

// a == {11 , 21 , 94 , 13}

9.3.3 La notion de blocked_range


Pour comprendre les exemples de base d’utilisation de parallel_for, il faut tout
d’abord comprendre la notion d’intervalle — blocked_range.
Le programme TBB 9.4 présente deux versions séquentielles d’une procédure pour
faire la somme de deux tableaux :
• additionner_seq : Style standard où les éléments à traiter sont spécifiés par
l’intermédiaire de bornes explicites — bornes inférieure inclusive (debut) et
borne supérieure exclusive (fin).
TBB 426

– Le type size_t représente un entier sans signe (donc non négatif) —


unsigned int.

• additionner_seq_range : Style où les éléments à traiter sont spécifiés par


un blocked_range :
– L’objet blocked_range<size_t>(0, n) représente l’intervalle d’entiers
[0, n) — donc 0, 1, 2, . . . , n − 1 (borne supérieure exclusive, donc n est
exclu).
– Les attributs begin() et end() permettent d’obtenir la borne inférieure
(inclusive) et la borne supérieure (exclusive).
– Les diverses méthodes d’un blocked_range sont présentées dans le pro-
gramme TBB 9.5.
TBB 427

Programme TBB 9.4 Deux versions séquentielles d’une procédure effectuant la


somme de deux tableaux.
// Premiere version sequentielle : style " standard " , i . e . , avec bornes explici
void additionner_seq ( const float a [] , const float b [] ,
float c [] ,
const size_t begin, const size_t end )
{
for ( size_t i = begin; i < end; i ++ ) {
c [ i ] = a [ i ] + b [ i ];
}
}

// Deuxieme version sequentielle : avec blocked_range .


void additionner_seq_range ( const float a [] , const float b [] ,
float c [] ,
const blocked_range<size_t> r )
{
for ( size_t i = r.begin(); i < r.end(); i ++ ) {
c [ i ] = a [ i ] + b [ i ];
}
}

// Appels , avec les declarations " float a [ n ] , b [ n ] et c [ n ]".


additionner_seq (a , b , c , 0, n);
additionner_seq_range (a , b , c , blocked_range<size_t>(0, n));
TBB 428

Programme TBB 9.5 Spécification du type blocked_range.


// tbb :: blocked_range < Value > Class Template Reference

// Public Member Functions

blocked_range ( Value begin_ ,


Value end_ ,
size_type grainsize_ =1)
// Construct range over half - open interval [ begin , end ) ,
// with the given grainsize .

Value begin () const


// Beginning of range .
Value end () const
// One past last value in range .

size_t size () const


// Size of the range .
size_t grainsize () const
// The grain size for this range .

bool empty () const


// True if range is empty .
bool is_divisible () const
// True if range is divisible .

blocked_range ( blocked_range &r , split )


// Split range .
TBB 429

9.3.4 Exemple : Somme de deux tableaux avec parallel_for


Le programme TBB 9.6 présente une version parallèle d’une procédure additionner_par
pour faire la somme de deux tableaux. Cette version utilise une λ-expression qui
contient directement le code séquentiel pour produire la tranche de tableau, mais
avec la tranche à traiter spécifiée par un blocked_range.

Programme TBB 9.6 Une version parallèle d’une procédure effectuant la somme
de deux tableaux.
// Version parallele avec lambda - expression et code explicite .
void additionner_par (
const float a [] ,
const float b [] ,
float c [] ,
const blocked_range < size_t > r )
{
parallel_for ( r ,
[=]( blocked_range < size_t > r ) {
for ( size_t i = r.begin(); i < r.end(); i++ ) {
c [ i ] = a [ i ] + b [ i ];
}
}
);
}

// Appel , avec les declarations " float a [ n ] , b [ n ] et c [ n ]".


additionner_par ( a , b , c ,
blocked_range < size_t >(0 , n ) );
TBB 430

9.4 Réduction : parallel_reduce


9.4.1 La spécification du parallel_reduce
En TBB, on utilise parallel_reduce pour effectuer des réductions. La forme la
plus simple est la forme fonctionnelle, où la réduction utilise un opérateur binaire
associatif représenté par Reduction :
template < typename Range ,
typename Value ,
typename Func ,
typename Reduction >
Value parallel_reduce ( const Range & range ,
const Value & identity ,
const Func & func ,
const Reduction & reduction );

• Range& range :
Intervalle à traiter

• Value identity :
Élément neutre de l’opérateur de réduction ; doit aussi être l’élément neutre
(à gauche) de Func::operator().

• Func::operator()( const Range& range, const Value& in ) :


Calcule le résultat pour l’intervalle range en utilisant in comme valeur initiale.

• Reduction::operator()( const Value& x, const Value& y ) :


Combine les résultats x et y.
Utilisée pour combiner les résultats produits par le traitement des différents
Ranges.
TBB 431

Figure 9.3: Fonctionnement d’une réduction avec parallel_reduce et illustration


du rôle des opérateurs func — qui réduit les valeurs d’un Range — et reduction
— qui réduit les résultats produits par les différents Range. On peut interpréter
le parallel_reduce comme réalisant une approche diviser-pour-régner récursive
(mais non dichotomique!), où func représente la résolution d’un sous-problème et
reduction représente la combinaison des solutions aux sous-problèmes.

9.4.2 Exemple : Sommation des éléments d’un tableau avec


parallel_reduce
Le programme TBB 9.8 présente un premier exemple d’utilisation de parallel_reduce :
la fonction sommePar calcule la somme des éléments d’un tableau a, de taille n.
Les arguments effectifs utilisés pour l’appel à parallel_reduce sont les suiv-
ants :

• L’intervalle d’index sur lequel la réduction doit se faire :


blocked_range<size_t>(0,n) ;

• L’élément neutre de l’addition : 0.f ;

• La fonction, une λ-expression, utilisée pour faire la somme, de façon séquen-


tielle itérative, des éléments d’un sous-intervalle non-divisible ;

• La fonction, une λ-expression, qui représente l’opérateur binaire d’addition.


Note : Cette λ-expression aurait pu être remplacée par l’expression suivante :
std::plus<float>().

Une version incorrecte, utilisant un parallel_for, est aussi présentée : sommePar-


INCORRECTE. Cette fonction est incorrecte car la variable somme est une variable
partagée, mais qui est lue et mise à jour sans être protégée par un verrou, ce qui
pourrait donc conduire à une condition de cours et à un résultat incorrect.
TBB 432

Programme TBB 9.7 Fonction sommePar pour calculer la somme des éléments d’un
tableau.
# include " tbb / parallel_reduce . h "
using namespace tbb ;

// Version INCORRECTE avec parallel_for .


float sommeParINCORRECTE ( float a [] , size_t n )
{
float somme = 0.0;
parallel_for (
blocked_range < size_t >(0 , n ) ,
[&]( blocked_range < size_t > r ) {
float sommeLocal = 0.0;
for ( size_t i = r . begin (); i < r . end (); i ++ ) {
sommeLocal += a [ i ];
}
somme += sommeLocal ;
}
);
return somme ;
}

Programme TBB 9.8 Fonction sommePar pour calculer la somme des éléments d’un
tableau.
// Version correcte avec parallel_reduce .
float sommePar ( float a [] , size_t n )
{
return parallel_reduce (
blocked_range < size_t >(0 , n ) ,
0. f ,
[=]( blocked_range < size_t > r , float acc ) {
for ( size_t i = r . begin (); i < r . end (); i ++ ) {
acc += a [ i ];
}
return acc ;
},
std :: plus < float >()
);
}
TBB 433

9.4.3 Exemple : Calcul de π avec parallel_reduce


Le programme TBB 9.9 présente un autre exemple d’utilisation de parallel_reduce :
la fonction calculDePi produit une approximation de la valeur de π en calculant,
de façon numérique (méthode des rectangles), la valeur de l’intégrale de la fonction
4
x2 +1
entre les bornes 0.0 et 1.0.
TBB 434

Programme TBB 9.9 Fonction calculDePi pour calculer la valeur de π.


# include " tbb / parallel_reduce . h "
using namespace tbb ;

double calculDePi ( const blocked_range < size_t > r ,


const double largeur );

int main ( int argc , char * argv [] )


{
int nb_rectangles = atoi ( argv [1]);
double largeur = 1.0 / ( double ) nb_rectangles ;

pi = calculDePi ( blocked_range < size_t >(0 , nb_rectangles ) ,


largeur );
printf ( " La valeur de pi est %15.12 f \ n " , pi );

return 0;
}

double calculDePi ( const blocked_range < size_t > r ,


const double largeur )
{
double somme = parallel_reduce (
r,
0. d ,
[=]( blocked_range < size_t > r , double somme ) {
for ( size_t i = r . begin (); i != r . end (); ++ i ) {
double x = ( i + 0.5) * largeur ;
somme += 4.0 / (1.0 + x * x );
}
return somme ;
},
std :: plus < double >()
);
return somme * largeur ;
}
TBB 435

9.4.4 Exercices : Génération d’un histogramme


Le programme C 9.1 présente une fonction séquentielle pour produire un histogramme
à partir d’un tableau d’entiers non-négatifs.

Voici un exemple d’appel :

elems == { 10, 1, 3, 3, 3, 2, 9, 1, 1, 1, 3, 10 }

histo = histogramme( elems, 12, 10 )

Voici la valeur de histo après l’appel :

histo == { 0, 4, 1, 4, 0, 0, 0, 0, 0, 1, 2 }

Question : Peut-on «facilement» paralléliser cette solution séquentielle (Pro-


gramme C 9.1)? Si oui de quelle façon? Sinon comment faire?
TBB 436

Programme C 9.1 Fonction séquentielle, en C, pour le calcul d’un histogramme.


/*
Fonction pour la generation d ’ un histogramme .

Donnees d ’ entree :
- elems : Tableau d ’ entiers non - negatifs
(0 ≤ elems [ i ] ≤ valMax )

- n : Taille du tableau elems

- valMax = Valeur maximum parmi les elements


d ’ elems

Resultat :
- Pointeur vers le tableau ( dynamique ) de
l ’ histogramme resultant
( alloue dynamiquement par la fonction )
*/

int * histogramme ( int elems [] , int n , int valMax )


{
// On alloue le tableau pour l ’ histogramme resultant .
int * histo
= ( int *) malloc ( ( valMax +1) * sizeof ( int ) );

// On initialise l ’ histogramme .
for ( int i = 0; i <= valMax ; i ++ ) {
histo [ i ] = 0;
}

// On construit l ’ histogramme en analysant les donnees .


for ( int i = 0; i < n ; i ++ ) {
histo [ elems [ i ]] += 1;
}

return histo ;
}
TBB 437

Programme C 9.2 Autre version d’une fonction séquentielle, en C, pour le calcul


d’un histogramme.
int nb_occurrences ( int val , int elems [] , int nb )
{
int nb_occ = 0;
for ( int k = 0; k < nb ; k ++ ) {
if ( elems [ k ] == val ) { nb_occ += 1; }
}

return nb_occ ;
}

int * histogramme ( int elems [] , int nb , int valMax )


{
// On alloue et on initialise l ’ histogramme .
int * histo
= ( int *) malloc ( ( valMax +1) * sizeof ( int ) );

for ( int val = 0; val <= valMax ; val ++ ) {


histo [ val ] = nb_occurrences ( val , elems , nb );
}
return histo ;
}
TBB 438

On veut paralléliser la fonction histogramme présentée plus haut (Programme


C 9.2), fonction qui produit un histogramme pour les entiers du tableau elems.

En utilisant les Threading Building Blocks d’Intel, écrivez une version parallèle de
la fonction histogramme. Utilisez une approche de parallélisme de résultat —
même si cela implique de parcourir plusieurs fois la matrice elems.

• Parallélisez tout d’abord histogramme. Ensuite, parallélisez nb_occurrences!

Exercice 9.2: Problème de l’histogramme.


TBB 439

Que fait la fonction suivante?


int * mystere ( int elems [] , int nb , int vm )
{
auto init = [ vm ]() {
int * h0 = ( int *) malloc ( ( vm +1) * sizeof ( int ) );
for ( int k = 0; k <= vm ; k ++ ) {
h0 [ k ] = 0;
}
return h0 ;
};

auto foo = [=]( blocked_range < size_t > r ,


int * h ) {
for ( auto i = r . begin (); i < r . end (); i ++ ) {
h [ elems [ i ]] += 1;
}
return h ;
};

auto bar = [ vm ]( int * h1 , int * h2 ) {


for ( int k = 0; k <= vm ; k ++ ) {
h1 [ k ] += h2 [ k ];
}
return h1 ;
};

return
parallel_reduce ( blocked_range < size_t >(0 , nb ) ,
init () ,
foo ,
bar
);
}

Exercice 9.3: Programme mystère.


TBB 440

9.5 Fonctionnement du parallel_for avec blocked_range


et rôle du partitioner
Note : Les explications qui suivent s’appliquent aussi au parallel_reduce.

• Le programme TBB 9.10 présente une mise en oeuvre de la classe blocked_range.


On remarque que la classe blocked_range possède deux (2) constructeurs :
un premier avec trois (3) arguments et un deuxième avec deux (2) arguments.
Les deux premiers arguments représentent les bornes, inférieure et supérieure,
de l’intervalle.
Le rôle du 3e argument, grainsize, est de spécifier la granularité des tâches,
c’est-à-dire de spécifier le nombre maximal d’itérations de boucle qu’une tâche
devrait traiter.
Plus précisément, si le nombre d’itérations à traiter est plus grand que grainsize,
alors cet intervalle d’itérations pourrait être divisé en deux sous-tâches avec
le constructeur de division de l’intervalle, R::R( R& r, split );.
Important : Lorsque la taille des grains n’est pas explicitement spécifiée
(constructeur avec deux arguments), alors grainsize == 1.

• Pour comprendre le rôle exact du grainsize, il faut savoir que la signature


exacte de parallel_for utilisant un Range est la suivante :1 codeSize
template < typename Range , typename Body >
void parallel_for (
const Range & range ,
const Body & body
[ , partitioner
[ , task_group_context & group ]] );

Les arguments partitioner et group sont donc optionnels, d’où leur absence
dans les exemples précédents. Dans les explications qui suivent, nous allons
nous concentrer sur le rôle du partitioner.
Le rôle d’un partitioner avec un Range est de déterminer jusqu’à quel point
les intervalles doivent effectivement être décomposés et distribués entre les
divers threads.
Les deux partitioners de base sont les suivants :
1
Il faut aussi savoir qu’il existe aussi des variantes de parallel_for qui utilisent directement
des Index plutôt qu’un Range. Voir l’URL suivant : http://www.threadingbuildingblocks.
org/docs/help/reference/algorithms/parallel_for_func.htm.
TBB 441

Programme TBB 9.10 Mise en oeuvre du type blocked_range.


template < typename T >
class blocked_range {
public :
blocked_range ( T begin , T end , size_t grainsize ) :
my_begin ( begin ) , my_end ( end ) , my_grainsize ( grainsize ) {}

blocked_range ( T begin , T end ) :


my_begin ( begin ) , my_end ( end ) , my_grainsize (1) {}

T begin () const { return my_begin ; }

T end () const { return my_end ; }

size_t size () const { return size_t ( my_end - my_begin ); }

size_t grainsize () const { return my_grainsize ; }

// Methodes pour Range.


bool empty () const { return !( my_begin < my_end ); }

bool is_divisible () const { return my_grainsize < size (); }

blocked_range ( blocked_range & r , split )


{
T middle = r . my_begin + ( r . my_end - r . my_begin ) / 2 u ;

// Nouveau blocked_range = intervalle superieur.


my_begin = middle ;
my_end = r . my_end ;

// blocked_range initial r = intervalle inferieur.


// r.my_begin est inchange.
r . my_end = middle ;

// Les deux intervalles ont la meme granularite.


my_grainsize = r . my_grainsize ;
}

private :
T my_begin ;
T my_end ;
size_t my_grainsize ;
};
TBB 442

// Split constructor
// = > definit un nouvel objet blocked_range
//
// r = range existant qu ’ on veut " splitter ".
//
blocked_range ( blocked_range & r , split )
{
T middle
= r . my_begin + ( r . my_end - r . my_begin ) / 2 u ;

// Nouveau blocked_range = intervalle droit.


my_begin = middle;
my_end = r.my_end;

// blocked_range initial r = intervalle gauche.


r.my_begin = r.my_begin; // i . e . , inchange !
r.my_end = middle;

// Les deux intervalles ont la meme granularite .


my_grainsize = r . my_grainsize ;
}
TBB 443

void parallel_for( Range range, Body body )


DEBU T
SI !range.is_divisible() ALORS
// Cas de base => traitement séquentiel via le body.
body( range )
SIN ON
// On décompose le range en deux sous-ranges
// avec le constructeur split
// (diviser-pour-régner dichotomique).
autre_range ← Range( range, split )

// On crée deux nouvelles tâches.


SPAWN parallel_for( autre_range, body )
SPAWN parallel_for( range, body )
F IN
F IN

Algorithme 9.4: Pseudocode (version simplifiée!) pour parallel_for utilisant


les méthodes d’un objet Range. . . en ne tenant pas compte du grainsize et du
partitioner().

– simple_partitioner() : Décompose récursivement un intervalle en sous-


intervalles jusqu’à ce que les intervalles résultants ne soient plus du tout
divisibles.
– auto_partitioner() : Décompose récursivement un intervalle en sous-
intervalles, jusqu’à ce qu’il y ait suffisament de travail pour les divers
threads, sans nécessairement aller jusqu’au point où les intervalles résul-
tants ne sont plus divisibles.
Or, le partitioner par défaut est auto_partitioner(). Les deux appels
suivants sont donc équivalents :
parallel_for ( r , lambdaExpr )
parallel_for ( r , lambdaExpr , auto_partitioner() )

Les deux appels suivants sont équivalents :


blocked_range < size_t >( i , j )
blocked_range < size_t >( i , j, 1 )

• On peut voir l’effet de l’approche diviser-pour-régner et le rôle du grainsize


lorsqu’un simple_partitioner() est utilisé dans l’extrait de programme TBB 9.11
(procédures additionner_par et additionner_seq) et dans l’exemple d’exécution 9.1
TBB 444

Programme TBB 9.11 Des appels à une procédure additionner par l’intermédiaire
d’un parallel_for utilisant un simple_partitioner(). La procédure appelée
affiche une trace d’exécution indiquant les bornes de l’intervalle à traiter.
void additionner ( const float a [] , const float b [] ,
float c [] ,
blocked_range < size_t > r )
{
printf ( " additionner ( a , b , c , [% d , % d ) )\ n " ,
r . begin () , r . end () );

for ( size_t i = r . begin (); i < r . end (); i ++ ) {


c [ i ] = a [ i ] + b [ i ];
}
}
...

const int N = 1000;


int a [ N ] , b [ N ] , c [ N ];
...
int grainsize = ...; // 200 , 100 , 1.
parallel_for (
blocked_range < size_t >(0 , N , grainsize) ,
[=]( blocked_range < size_t > r ) {
additionner ( a , b , c , r );
},
simple_partitioner()
);
TBB 445

// int grainsize = 200;


additionner ( a, b, c, [0 , 125) )
additionner ( a, b, c, [500 , 625) )
additionner ( a, b, c, [250 , 375) )
additionner ( a, b, c, [375 , 500) )
additionner ( a, b, c, [125 , 250) )
additionner ( a, b, c, [750 , 875) )
additionner ( a, b, c, [625 , 750) )
additionner ( a, b, c, [875 , 1000) )

// int grainsize = 100;


additionner ( a, b, c, [500 , 562) )
additionner ( a, b, c, [750 , 812) )
additionner ( a, b, c, [875 , 937) )
additionner ( a, b, c, [937 , 1000) )
additionner ( a, b, c, [812 , 875) )
additionner ( a, b, c, [562 , 625) )
additionner ( a, b, c, [625 , 687) )
additionner ( a, b, c, [687 , 750) )
additionner ( a, b, c, [0 , 62) )
additionner ( a, b, c, [250 , 312) )
additionner ( a, b, c, [125 , 187) )
additionner ( a, b, c, [375 , 437) )
additionner ( a, b, c, [62 , 125) )
additionner ( a, b, c, [312 , 375) )
additionner ( a, b, c, [187 , 250) )
additionner ( a, b, c, [437 , 500) )

// int grainsize = 1;
additionner ( a, b, c, [500 , 501) )
additionner ( a, b, c, [750 , 751) )
additionner ( a, b, c, [875 , 876) )
additionner ( a, b, c, [812 , 813) )
.
.
.
additionner ( a , b , c , [59 , 60) )
additionner ( a , b , c , [60 , 61) )
additionner ( a , b , c , [626 , 627) )
...
additionner ( a , b , c , [484 , 485) )
additionner ( a , b , c , [371 , 372) )
...
additionner ( a , b , c , [498 , 499) )
additionner ( a , b , c , [499 , 500) )
additionner ( a , b , c , [467 , 468) )

Exemple d’exécution 9.1: Exemples d’exécution de la procé-


dure additionner_par pour diverses valeurs de grainsize avec un
simple_partitioner() : 200, 100 et 1.
TBB 446

qui présente une trace d’exécution pour différents appels de la procédure


additionner_par.
On constate qu’avec un simple_partitioner(), dans tous les cas la taille
de l’intervalle à traiter (donc la taille de la tâche) est inférieure ou égale à la
valeur spécifiée pour la taille du grain. Et lorsque grainsize=1, cela signifie
alors que chaque item du résultat c correspond à une tâche indépendante.
Par exemple, une variante de ce programme — variante où la boucle de somma-
tion de la procédure additionner est répétée plusieurs fois (pour augmenter
le temps d’exécution) — a été exécutée avec des tableaux comptant 10 000
éléments. Les diverses exécutions ont généré les nombres de tâches suivants
selon le partitioner utilisé :

– simple_partitioner() : toujours 10 000 tâches ;


– auto_partitioner() : environ 1 000 tâches, le nombre exact variant
d’une exécution à une autre (988, 1033, etc.).

L’utilisation d’un auto_partitioner() peut donc réduire considérablement


les surcoûts de création de sous-tâches.

• Le comportement de la méthode générique parallel_for en présence d’un


simple_partitioner() peut être illustré à l’aide de pseudocode :

– La deuxième version présente le pseudocode du comportement plus «réel»,


qui utilise les méthodes d’un objet Range : voir l’algorithme 9.4.

Donc : avec le simple_partitioner(), la décomposition se fait toujours


jusqu’à ce que la taille du sous-problème soit ≤ grainsize!
Un point important à souligner dans ce pseudocode est l’utilisation du mot
clé SPAWN. C’est un terme, utilisé dans le contexte du langage Cilk [FLR98,
MRR12]), pour dénoter la création d’une nouvelle tâche — pas nécessairement
un nouveau thread ! Voir section 9.10.

• Lorsqu’on utilise un simple_partitioner(), la règle heuristique suggérée est


la suivante [Rei07] : l’exécution de grainsize itérations de operator() de-
vrait entraîner l’exécution de 10 000 à 100 000 instructions machines.
En fait, le choix de la valeur pour la taille des grains est important, mais
en général on a quand même une assez grande flexibilité dans le choix de
cette valeur. En d’autres mots, le choix n’est pas tant entre n et n + 1. . .
qu’entre 10n et 10n+k : voir la figure 9.4 [Rei07] (échelle logarithmique pour
le temps d’exécution).
TBB 447

Figure 9.4: Courbe (avec échelle logarithmique) illustrant l’effet typique de la


taille des grains sur le temps d’exécution d’un programme TBB [Rei07] lorsqu’un
simple_partitioner() est utilisé.

C’est ce qui fait que, dans la version plus récente de TBB, on suggère d’utiliser
plutôt un auto_partitioner(), et donc de laisser au système d’exécution TBB
le choix de déterminer si l’intervalle doit ou non être décomposé, en fonction
de la charge de travail des threads. C’est donc pour cette raison que c’est le
auto_partitioner() qui est utilisé par défaut, lorsque cet argument est omis.
TBB 448

9.6 Parallélisme de contrôle : parallel_invoke et


task_group
Il est possible d’activer en parallèle un ensemble de fonctions à l’aide de la structure
de contrôle parallel_invoke, où les signatures des différentes versions sont définies
comme suit :
// Deux fonctions en parallele .
template < typename Func0 ,
typename Func1 >
void parallel_invoke ( const Func0 & f0 ,
const Func1 & f1 );

// Trois fonctions en parallele .


template < typename Func0 ,
typename Func1 ,
typename Func2 >
void parallel_invoke ( const Func0 & f0 ,
const Func1 & f1 ,
const Func2 & f2 );

.
.
.

// Dix fonctions en parallele .


template < typename Func0 ,
typename Func1 ,
... ,
typename Func9 >
void parallel_invoke ( const Func0 & f0 ,
const Func1 & f1 ,
... ,
const Func9 & f9 );
On peut donc activer jusqu’à 10 fonctions en parallèle. On doit noter toutefois
que ces fonctions doivent en fait être des procédures, puisque le type de retour doit
nécessairement être void.
Le programme TBB 9.12 présente une fonction fibo_par — et sa procédure
auxiliaire fibo_par_rec — pour calculer le nième nombre de Fibonacci, et ce à
l’aide d’une approche naïve de parallélisme récursif.
TBB 449

Programme TBB 9.12 Fonction pour calculer le nième nombre de Fibonacci à l’aide
de parallélisme récursif réalisé avec parallel_invoke.
# include " tbb / parallel_invoke . h "
using namespace tbb ;

int fibo_par ( int n )


{
if ( n == 1 || n == 2 ) {
return 1;
} else {
int r1 , r2 ;
parallel_invoke ( [&] { r1 = fibo_par (n -1); } ,
[&] { r2 = fibo_par (n -2); } );
return r1 + r2 ;
}
}
TBB 450

On peut aussi utiliser des groupes de tâches, tel que présenté dans le programme
TBB 9.13. Les opérations associées à un tel objet sont les suivantes :2
template < typename Func >
void run ( const Func & f );

template < typename Func >


void run ( task_handle < Func >& handle );

template < typename Func >


void run_and_wait ( const Func & f );

template < typename Func >


void run_and_wait ( task_handle < Func >& handle );

task_group_status wait ();

bool is_canceling ();

void cancel ();

Programme TBB 9.13 Fonction pour calculer le nième nombre de Fibonacci à l’aide
de parallélisme récursif réalisé avec un task_group.
# include " tbb / parallel_invoke . h "
using namespace tbb ;

int fibo_par ( int n )


{
if ( n == 1 || n == 2 ) {
return 1;
} else {
int r1 , r2 ;
task_group g ;
g . run ( [&]{ r1 = fibo_par (n -1); } );
g . run ( [&]{ r2 = fibo_par (n -2); } );
g . wait ();
return r1 + r2 ;
}
}

2
http://www.threadingbuildingblocks.org/docs/help/reference/task_groups/task_
group_cls.htm
TBB 451

9.7 Tri parallèle : parallel_sort


La bibliothèque TBB fournit un procédure générique de tri. Le tri est instable —
l’ordre des éléments «égaux» n’est pas nécessairement préservé — mais déterministe
— le résultat est toujours le même.
template < typename RandomAccessIterator >
void parallel_sort ( RandomAccessIterator begin ,
RandomAccessIterator end );

template < typename RandomAccessIterator , typename Compare >


void parallel_sort ( RandomAccessIterator begin ,
RandomAccessIterator end ,
const Compare & comp );

Les propriétés requises d’un objet RandomAccessIterator sont les suivantes :


void swap ( T & x , T & y )
// Interchange les deux elements .

bool Compare :: operator ()( const T & x , const T & y )


// Vrai si x vient avant y dans la sequence triee .
Le programme TBB 9.14 présente un bref exemple.
TBB 452

Programme TBB 9.14 Un exemple de programme de tri.


# include " tbb / parallel_sort . h "

using namespace tbb ;

void initialiser ( int a [] , int N ) { ... }

const int N = 100;

int main ( int argc , char * argv [] ) {

int a [ N ] = ...;

...

// Tri en ordre croissant .


parallel_sort ( a , a + N );
...

// Tri en ordre decroissant .


parallel_sort ( a , a + N , std :: greater < int >() );
...
}
TBB 453

9.8 Parallélisme de flux : pipeline


L’évaluation d’un polynôme en une série de points peut être réalisée à l’aide de
parallélisme par flux en utilisant la méthode de Horner d’évaluation des polynômes.
Par exemple, soit un polynôme p composé de quatre coefficients et défini ainsi :
p(x) = a + bx + cx2 + dx3
Ce polynome peut alors être évalué comme suit — méthode de Horner :
p(x) = (((((d ∗ x) + c) ∗ x) + b) ∗ x) + a
Soit alors les fonctions suivantes :
f0 (x, y) = (x, y ∗ x + d)
f1 (x, y) = (x, y ∗ x + c)
f2 (x, y) = (x, y ∗ x + b)
f3 (x, y) = (x, y ∗ x + a)

L’évaluation du polynôme p(x) peut aussi être exprimée comme suit :


p(x) = r2
where (r1 , r2 ) = f3 (f2 (f1 (f0 (x, 0))))

p(x) = r2
where (r1 , r2 ) = f3 (f2 (f1 (f0 (x, 0))))

(r1 , r2 ) = f3 (f2 (f1 (f0 (x, 0))))


= f3 (f2 (f1 (x, d)))
= f3 (f2 (x, d ∗ x + c))
= f3 (x, (d ∗ x + c) ∗ x + b)
= (x, ((d ∗ x + c) ∗ x + b) ∗ x + a)
= (x, (d ∗ x + c) ∗ x2 + b ∗ x + a)
= (x, d ∗ x3 + c ∗ x2 + b ∗ x + a)
TBB 454

Donc :
p(x) = r2 = dx3 + cx2 + bx + a
Cette façon d’exprimer l’évaluation à l’aide d’une série de fonctions conduit alors
à du parallélisme de spécialistes associé à du parallélisme de flux pour l’évaluation
du polynome en un grand nombre de valeurs : chaque processus (filtre) du pipeline
traite les différentes valeurs, mais pour un coefficient spécifique, et c’est la com-
position des fonctions du pipeline qui permet d’obtenir l’évaluation pour un point
donné.
Cette méthode peut évidemment être généralisée à un nombre arbitraire de co-
efficients.

• Le programme TBB 9.15 définit une classe pour représenter des Paires de
valeurs.

• Le programme TBB 9.16 définit trois sortes de filtres :

– GenererVals : Processus source, unique, qui injecte dans le pipeline les


différentes valeurs à traiter ;
– EvaluerCoeff : Processus filtre, multiples, où chaque instance traite un
coefficient spécifique ;
– AccumulerResultats : Processus puits, unique, qui accumule dans un
tableau les différents résultats.

• Le programme TBB 9.17 crée ensuite les différents filtres, puis les compose en
un pipeline.

Programme TBB 9.15 Classe Paire pour représenter des tuples formés de deux
composants.
class Paire {
public :
double x ;
double y ;
public :
Paire ( double x , double y ) :
x ( x ) , y ( y ) {}
};
TBB 455

Programme TBB 9.16 Trois sortes de filter pour manipuler des valeurs et des
coefficients pour évaluer un polynome.
class GenererVals : public filter {
int prochain = 0;
int nbVals ;
double * vals ;
public :
GenererVals ( double * vals , int nbVals ) :
filter ( serial_in_order ) , nbVals ( nbVals ) , vals ( vals ) {}

void * operator ()( void * ) {


if ( prochain < nbVals ) {
return new Paire ( vals [ prochain ++] , 0. d );
} else {
return NULL ;
}
}
};

class EvaluerCoeff : public filter {


double coeff ;
public :
EvaluerCoeff ( double coeff ) :
filter ( serial_in_order ) , coeff ( coeff ) {}

void * operator ()( void * v ) {


Paire * p = ( Paire *) v ;
p - > y = p - > x * p - > y + coeff ;
return p ;
}
};

class Acc um ul erR es ul tat s : public filter {


double * resultats ;
int suivant = 0;
public :
Ac cu mu ler Re su lta ts ( double * resultats ) :
filter ( serial_in_order ) , resultats ( resultats ) {}

void * operator ()( void * v ) {


Paire * p = ( Paire *) v ;
resultats [ suivant ++] = p - > y ;
free ( p );
}
};
TBB 456

Programme TBB 9.17 Programme TBB pour évaluer un polynome en une série de
points, à l’aide d’une approche fondée sur le parallélisme de flux, et utilisant les
filtres du programme TBB 9.16.
//
// Exemple d ’ utilisation .
//

//
// On suppose qu ’ on a les trois tableaux suivants :
// coeffs : les nbCoeffs coefficients definissant le polynome
// vals : les nbVals valeurs a utiliser pour evaluer le polynome
// resultats : les nbVals resultats
//
pipeline p ;

// Le producteur ( la source ) qui genere les differentes valeurs .


GenererVals generateurVals ( vals , nbVals );
p . add_filter ( generateurVals );

// Les differents filtres pour chacun des coefficients .


for ( int i = 0; i < nbCoeffs ; i ++ ) {
p . add_filter ( *( new EvaluerCoeff ( coeffs [ i ])) );
}

// Le consommateur ( sink , puits ) qui accumule les divers resultats .


AccumulerResultats accumulerResultats ( resultats );
p . add_filter ( accumulerResultats );

p . run ( nbCoeffs );

// Les resultats sont maintenant disponible dans le tableau resultats .


TBB 457

9.9 Mesures de performance


9.9.1 Mesures du temps d’exécution
Pour mesurer le temps d’exécution d’un segment de code, on utilise tick_count, une
classe dont le comportement est semblable à ce qu’on retrouve dans de nombreux
langages ou bibliothèques de programmation parallèle :
Description
A tick_count is an absolute timestamp. Two tick_count objects may
be subtracted to compute a relative time tick_count::interval_t, which
can be converted to seconds.
Le programme TBB 9.18 présente un court exemple tiré de la documentation
TBB.3
Programme TBB 9.18 Un petit exemple illustrant la mesure du temps d’exécution
d’un segment de code.
# include " tbb / tick_count . h "
using namespace tbb ;

void foo ()
{
tick_count t0 = tick_count :: now ();
... action being timed ...
tick_count t1 = tick_count :: now ();

printf ( " time for action = % g seconds \ n " ,


( t1 - t0 ). seconds () );
}

9.9.2 Dimensionabilité
Règle générale, dans un programme TBB, on laisse le soin au système d’exécution de
choisir le nombre de threads ; on se concentre plutôt sur l’identification des
tâches.
Par contre, il est quand même possible — et parfois utile — de contrôler le
nombre de threads utilisés pour exécuter un programme. Pour ce faire on utilise un
objet task_scheduler_init spécifié comme suit :
3
http://www.threadingbuildingblocks.org/docs/help/reference/timing/tick_count_
cls.htm
TBB 458

task_scheduler_init (
int max_threads = automatic ,
stack_size_type thread_stack_size =0
)
Comme l’indique la documentation TBB : «An optional parameter to the con-
structor [. . . ] allows you to specify the number of threads to be used for task exe-
cution. This parameter is useful for scaling studies during development,
but should not be set for production use.»
Donc, on utilise un tel objet uniquement pour déterminer dans quelle mesure un
programme TBB est dimensionable (scalable), i.e., quel est l’effet de varier le nombre
de threads sur les performances du programme.
Le programme TBB 9.19 présente un squelette de programme permettant de faire
de telles mesures de dimensionabilité.

Programme TBB 9.19 Squelette de programme pour déterminer l’effet du nombre


de threads sur les performances d’un programme TBB.
# include " tbb / task_scheduler_init . h "

using namespace tbb ;

int main ( int argc , char * argv [] )


{
int nb_threads
= task_scheduler_init :: default_num_threads ();

for ( int k = 1; k <= nb_threads ; k *= 2 ) {


printf ( " Execution avec % d thread ( s )\ n " , k );

task_scheduler_init init ( k ); // Allocation statique .


... code dont on veut mesurer les performances ...
.
.
.

// Fin du bloc : l ’ objet task_scheduler_init est libere .


}
}
TBB 459

9.10 Approche fondée sur les tâches et ordonnance-


ment par «vol de tâches»
Another advantage of tasks versus logical threads is that tasks are much
lighter weight. On Linux systems, starting and terminating a task
is about 18 times faster than starting and terminating a thread. On
Windows systems, the ratio is more than 100-fold.
Source : «Intel Threading Building Blocks: Outfitting C++ for Multi-
Core Processor Parallelism», Reinders, 2007.

[F]or scheduling loop iterations, Threading Building Blocks does not re-
quire the programmer to worry about scheduling policies. Threading
Building Blocks does away with this in favor of a single, automatic,
divide-and-conquer approach to scheduling. Implemented with
work stealing (a technique for moving tasks from loaded processors
to idle ones), it compares favorably to [OpenMP’s] dynamic or guided
scheduling, but without the problems of a centralized dealer.
Source : «Intel Threading Building Blocks: Outfitting C++ for Multi-
Core Processor Parallelism», Reinders, 2007.

Le modèle de programmation TBB est fondé sur la décomposition en tâches,


tâches qui ne correspondent pas nécessairement à des threads. Ainsi, bien que la
bibliothèque TBB fournisse des mécanismes permettant d’activer et manipuler ex-
plicitement des threads, ce n’est pas le modèle ou l’approche privilégiée.
Le modèle d’exécution de TBB doit plutôt être vu comme une approche de style
«sac de tâches», donc avec association dynamique entre tâches et threads : le
programmeur identifie et décompose son problème en tâches et sous-tâches, puis
laisse le soin au système d’exécution d’affecter les tâches ainsi créées aux différents
threads. Quant à ces threads, ils sont crées par le système d’exécution en fonction
des ressources (processeurs ou coeurs physiques) disponibles sur la machine.
Cette approche fondée sur les tâches est efficace grâce à la façon dont l’ordonnan-
cement des tâches aux threads est effectuée. L’approche utilisée est celle introduite
par Cilk [FLR98, MRR12], fondée sur le vol de tâches (work stealing).
En gros4 , voici comment fonctionne le traitement des tâches par les threads :

• Chaque thread utilise un deque (double ended queue) ayant les opérations
suivantes :
4
Le modèle présenté, utilisant un deque, est une des façons possibles de mettre en oeuvre cette
approche. D’autres structures de données plus complexes pour la mise en oeuvre du sac de tâches
sont possibles, par exemple, une pile de files : voir [Rei07].
TBB 460

empty? : Indique si le deque de tâches est


vide ou non.
push Ajoute une tâche à la tête du
deque (dont à la tête du deque).
pop Retire une tâche de la tête du
deque et la retourne.
remove Retire une tâche de la queue du
deque et la retourne.

• Soit D le deque associé à un certain thread T .

– Lorsque T crée une nouvelle tâche (SPAWN), alors l’ajout se fait avec
D.push.
– Lorsque T termine le traitement d’une tâche, alors son comportement est
le suivant pour obtenir une nouvelle tâche à exécuter :
∗ Si D.empty? est false, alors il va traiter une tâche locale. Donc la
prochaine tâche à traiter est , obtenue avec D.pop.
∗ Si D.empty? est true, alors T va voler une tâche à un autre thread
T 0 choisi aléatoirement (s’il y en a un qui a des tâches dans son
deque). Donc la prochaine tâche à traiter estobtenue avec D0 .remove,
où D0 est le deque du thread T 0 qui est «victime du vol»!.
Fait : Lorsqu’il y a suffisamment de tâches locales à traiter, le deque est
donc manipulé comme une pile.

• Les avantages de l’approche avec work stealing sont les suivants :

– Le coût pour traiter une tâche locale (non volée) est faible — à toute fin
pratique, ce coût est équivalent à celui d’un simple appel de procédure/-
fonction.
Le coût de création et traitement d’une tâche n’est élevé que lorsque cette
tâche «migre» du deque d’un thread (victime) au deque d’un autre thread
(voleur).
– L’arbre des tâches est exploré localement en profondeur (depth-first), ce
qui minimise l’espace requis pour les appels (et blocs d’activation) tout
en utilisant efficacement les caches — on dit que le code s’exécute alors
pendant que le cache est chaud.
L’exploration en largeur (breadth-first) ne se fait que si un ou des threads
sont effectivement libres (sans tâche à exécuter) et n’ont aucun travail
à effectuer. Ceci favorise alors l’exploration en parallèle, puisque ces
TBB 461

tâches sont plus hautes dans l’arbre d’exécution, mais sans en payer le
côut lorsque le parallélisme ne peut pas être exploité faute de ressources
(threads et processeurs).
Voir le document suivant :
http://www.labunix.uqam.ca/~tremblay/INF5171/Materiel/vol-de-taches.
pdf

To summarize, the task scheduler’s fundamental strategy is breadth-


first theft and depth-first work. The breadth-first theft rule raises
parallelism sufficiently to keep threads busy. The depth-first work rule
keeps each thread operating efficiently once it has sufficient work to do.
Source : «Intel Threading Building Blocks: Outfitting C++ for Multi-
Core Processor Parallelism», Reinders, 2007.
TBB 462

9.11 L’approche «Diviser-pour-régner» et la notion


de Range
En TBB, la décomposition en sous-problème avec les Range peut être beaucoup plus
complexe qu’une simple modification des bornes!
Dans ce qui suit, deux versions du tri rapide :
1. Version «ordinaire»
2. Version où tout travail de décomposition se fait lors de la création des Ranges.
Dans cette section, nous allons comment un Range peut être relativement com-
plexe : la décomposition en sous-problèmes peut impliquer beaucoup plus de travail
qu’une simple modification des bornes, comme dans le cas d’un blocked_range.
Plus spécifiquement, nous allons examiner deux mises en oeuvre d’une procédure
de tri rapide — quicksort :
• Le programme TBB 9.11 présente une première version parallèle — procédure
parallel_qs_rec.
Cette première version est relativement semblable à une version séquentielle,
puisque la procédure parallel_qs_rec utilise une stratégie de parallélisme
récursif explicite.
Dans le cas présent, on utilise donc un appel à parallel_invoke, avec deux
tâches à exécuter, et ce après que le partitionnement en deux moitiés à trier
ait été effectué par la procédure partitioner.
• Le programme TBB 9.21 présente une deuxième version parallèle — procédure
parallel_qs.
Cette deuxième version utilise un parallel_for (sic!), mais en utilisant un
range avec un comportement particulier, qs_range.
C’est à l’intérieur du constructeur de division de l’intervalle — le deuxième
constructeur qs_range avec un deuxième argument split — que le parti-
tionnement en deux sous-tableaux s’effectue, toujours à l’aide de la procédure
partitionner.
Les figures 9.5 et 9.6 présentent des mesures de temps d’accélération et temps
d’exécution effectuées sur deux variantes de ces deux procédures :
• Dans la première variante (indiqué par p0), le choix du pivot est effectué tel
qu’indiqué dans le programme TBB 9.11 : on choisit toujours l’élément à la
position 0 du tableau comme pivot, donc comme élément à partir duquel on
va décomposer en deux sous-problèmes — les éléments plus petits que le pivot
vs. les éléments plus grands.
TBB 463

Programme TBB 9.20 Une mise en oeuvre parallèle du tri rapide (quicksort) avec
une approche style parallélisme récursif, et avec un seuil de récursion.
void partitionner ( int * a , size_t taille , size_t & posPivot )
{
// Pour simplifier : On selectionne le premier element comme pivot .
auto pivot = a [0];
posPivot = 0;

// On trouve la position finale ou devra aller le pivot ,


// en deplacant les elements lorsque requis .
for ( size_t i = 1; i < taille ; i ++ ) {
if ( a [ i ] < pivot ) {
posPivot += 1;
swap ( a [ i ] , a [ posPivot ] );
}
}

// On met le pivot a sa bonne position .


swap ( a [ posPivot ] , a [0] );
// ASSERT : a [0: posPivot ] <= pivot < a [ posPivot +1: taille -1]
};

void parallel_qs_rec ( int * a , size_t n , size_t seuil ) {


if ( n <= seuil ) {
// Cas de base .
std :: sort ( a , a +n , std :: less < int >() );
} else {
// Cas recursif .
size_t posPivot ;

partitionner ( a , n , posPivot );

tbb :: parallel_invoke (
[=]{ parallel_qs_rec (a , posPivot , seuil ); } ,
[=]{ parallel_qs_rec ( a + posPivot +1 , n - posPivot -1 , seuil ); }
);
}
};
TBB 464

Programme TBB 9.21 Une mise en oeuvre du tri rapide (quicksort) avec un objet
de type Range, qui effectue le partitionnement et qui fait tout le travail, utilisé dans
un parallel_for.

struct qs_range {
int * a ;
size_t taille , seuil ;

qs_range ( int * a , size_t taille , size_t seuil )


: a ( a ) , taille ( taille ) , seuil ( seuil ) {}

bool empty () const { return taille == 0; }

bool is_divisible () const { return taille > seuil ; }

qs_range ( qs_range & range , split ) {


size_t posPivot ;
partitionner ( range .a , range . taille , posPivot );

// Le nouveau range traitera la partie droite .


a = range . a + posPivot + 1;
taille = range . taille - posPivot - 1;

// Le range qui vient d ’ etre " splitte " traitera la gauche .


range . a = range . a ;
range . taille = posPivot ;
range . seuil = seuil ;
}
};

void parallel_qs ( int * a , size_t n , size_t seuil ) {


parallel_for (
qs_range (a , n , seuil ) ,
[]( const qs_range range ) {
// On utilise la procedure sequentielle de la bibliotheque .
std :: sort ( range .a , range . a + range . taille , std :: less < int >());
}
);
};
TBB 465

• Dans la deuxième variante, le choix du pivot est effectué comme suit :

– On examine trois éléments du tableau a : le premier élément, le dernier


et l’élement au milieu du tableau.
– On calcule la médiane de ces trois éléments — l’élément milieu, qui n’est
ni le plus petit, ni le plus grand.
– C’est cet élément médiane qui est alors sélectionné comme pivot.

Figure 9.5: Accélérations obtenues pour différentes tailles de tableau (échelle loga-
rithmique) et pour les deux variantes des deux procédures de tri rapide avec deux
façons de choisir le pivot.
TBB 466

Figure 9.6: Temps d’exécution obtenus pour différentes tailles de tableau (échelle
logarithmique) et pour les deux variantes des deux procédures de tri rapide avec
deux façons de choisir le pivot.
TBB 467

Temps d’exécution obtenus sur japet.labunix.uqam.ca (automne 2015) —


pour une taille donnée, le temps en rouge indique le meilleur temps :

# pivot = mediane
# n seq p_for p_invoke
4000 0.0011 0.0051 0.0030
8000 0.0025 0.0082 0.0016
16000 0.0052 0.0103 0.0026
32000 0.0118 0.0100 0.0031
64000 0.0250 0.0141 0.0063
128000 0.0544 0.0187 0.0095
256000 0.1151 0.0296 0.0230
512000 0.2558 0.0509 0.0462
1024000 0.5174 0.0859 0.0928
2048000 1.0928 0.1589 0.1804

# pivot = pos0
# n seq p_for p_invoke
4000 0.0012 0.0069 0.0008
8000 0.0026 0.0062 0.0014
16000 0.0058 0.0094 0.0020
32000 0.0130 0.0096 0.0036
64000 0.0273 0.0159 0.0072
128000 0.0584 0.0200 0.0123
256000 0.1259 0.0342 0.0290
512000 0.2692 0.0693 0.0508
1024000 0.5771 0.1145 0.1256
2048000 1.2233 0.2006 0.2444

Quelques constatations :

• Si on examine uniquement les accélérations, on constate que la version avec


parallel_for et choix simple du pivot (pivot=a[0]) génère de bonnes ac-
célérations — et ce par rapport à la version séquentielle utilisant la même
approche de choix de pivot.

• Si on examine les temps d’exécution — voir détails dans le tableau plus haut —
on constate que c’est la version avec parallel_for et le choix de pivot qui «ap-
proxime» (naivement) la médiane qui génère les meilleurs temps d’exécution.
Et la version parallel_invoke avec choix du pivot approximant la médiane
a aussi de meilleurs temps d’éxécution, même si son accélération relative à la
version séquentielle (avec même choix de pivot) n’est pas aussi bonne.
TBB 468

Conclusion : Les accélérations — et la dimensionabilité, i.e., la capacité à maintenir


des accélérations en augmentant la taille du problème — sont des caractéristiques
importantes des programmes parallèles. . . mais le temps d’exécution ne doit jamais
être ignoré!
Chapitre 10

Programmation parallèle avec


OpenMP/C

10.1 Introduction
Ce chapitre présente un bref aperçu d’OpenMP = «Open Multi-Processing».

• OpenMP est une interface de programmation parallèle pour architectures à mé-


moire partagée.

• OpenMP n’est pas un langage en soi. OpenMP fournit plutôt un ensemble de


directives (pragmas), routines et variables d’environnement, disponible dans
différents langages. Dans ce qui suit, nous verrons des exemples en C.

• OpenMP a été définie et appuyée par un grand groupe de constructeurs de


matériel et de logiciel (standard de facto) : Compaq/Digital, HP, Intel, IBM,
Silicon Graphics, Sun, USDE, etc.

• Dernière version = 4.5 (Novembre 2015)

• OpenMP est fondé sur le modèle fork/join : le programme commence avec un


unique thread (appelé le «thread maître»), puis se duplique (il fork ) en une
équipe de threads, et ce à l’intérieur de ce qu’on appelle une «région parallèle».
La fin de la région représente alors une barrière de synchronisation où les
threads doivent attendre que tous les threads aient terminé avant de pouvoir
poursuivre l’exécution.

469
OpenMP 470

Voici une définition générale du modèle fork/join :1

fork–join model: (computer science) A method of programming


on parallel machines in which one or more child processes branch
out from the root task when it is time to do work in parallel, and
end when the parallel work is done.

• Une région parallèle est délimitée par une instruction, simple ou composite,
i.e., un bloc d’instructions — instructions comprises entre «{» et «}».

• OpenMP est «excellent for Fortran-style code written in C » [Rei07].


1
http://www.answers.com/topic/fork-join-model
OpenMP 471

Figure 10.1: Représentation graphique du modèle «fork/join» à la OpenMP

L’API OpenMP 4.5 pour C/C++ contient un grand nombre de directives et


constructions :
http://www.openmp.org/wp-content/uploads/OpenMP-4.5-1115-CPP-web.pdf
Dans ce qui suit, on va voir les principales directives, qui forment l’essence
d’OpenMP — si vous les comprenez, vous pourrez vous débrouiller avec le reste!
OpenMP 472

10.2 Directives et opérations de base


• Pragma de base = omp parallel : Crée une région parallèle, exécutée par une
équipe de threads, où tous les threads exécutent tout le code dans la région.
Une barrière est implicitement présente à la fin, donc tous les threads attendent
avant de poursuivre au-delà de la région parallèle.
# pragma omp parallel
{
printf ( " Hello du thread % d \ n " ,
omp_get_thread_num () );
...
printf ( " ...\ n " );
}
OpenMP 473

• OpenMP est un modèle de programmation pour architecture avec mémoire


partagée. La règle de base est donc qu’une variable allouée avant (au niveau
du code source) le début d’une région parallèle est partagée par les divers
threads. Par cette règle, une variable d’indexation d’une boucle for sera con-
sidérée privée si elle est déclarée dans l’en-tête du for.
int x = 0; // Variable partagee entre threads

# pragma omp parallel


{
// Acces , non protege ?! , a une var . partagee
x = x + 1;

// Variable y locale a chaque thread .


int y = x + 1;

...
}

Si une copie locale d’une variable globale doit plutôt être utilisée, on doit le
spécifier explicitement avec une clause private :
int x = 0;

# pragma omp parallel private( x )


{
// Copie locale de x .
x = x + 1;

// Variable y locale a chaque thread .


int y = x + 1;

...
}
OpenMP 474

• Il existe différentes façons d’indiquer le nombre de threads désirés :

• Avec un pragma :
# pragma omp parallel num_threads (10)

• Avec une instruction :


omp_set_num_threads (10);

• Avec la variable d’environnement :


export OMP_NUM_THREADS =10

Dans tous les cas, il s’agit d’une «suggestion», et non pas une «obligation»
pour le système d’exécution d’allouer exactement le nombre de threads in-
diqués — en d’autres mots, cela permet de spécifier le nombre maximum de
threads qu’on désire utiliser.
OpenMP 475

• Les clauses de partage de travail entre threads (work sharing) permettent de


distribuer le travail à effectuer dans une région parallèle entre les différents
threads d’une équipe (active) de threads. (Une barrière est aussi implicitement
présente à la fin de la construction de work sharing.) Ces clauses doivent
apparaître à l’intérieur d’une région parallè où une équipe de threads est déjà
active. Des abréviations combinant les deux aspects (lancement de l’équipe
de threads et partage du travail) sont aussi disponibles — voir plus bas.

– Répartition des itérations d’un for (loop splitting) : les diverses itérations
de la boucle for qui suit sont réparties entre les différents threads :
# pragma omp for
for ( ... ) {
...
}

– Distribution par sections : chacune des section qui suit est exécutée par
un unique thread.
# pragma omp sections
{

# pragma omp section


{ ... code pour 1 ere section ,
executee par un thread ... }

# pragma omp section


{ ... code pour 2 e section ,
executee par un autre thread ... }
...

– Création dynamique de tâches : Voir plus loin.


OpenMP 476

Deux petits exemples


Premier exemple :
void foo ( int n , int nb_threads ) {
printf ( " foo ( %d , % d )\ n " , n , nb_threads );

omp_set_num_threads ( nb_threads );

# pragma omp parallel


for ( int i = 0; i < n ; i ++ ) {
int id = omp_get_thread_num ();
printf ( " i = % d : id = % d \ n " , i , id );
}
}

Quel est l’effet d’un appel à foo( 5, 3 )?


Exercice 10.1: Effet d’un appel à foo( 5, 3 ).
OpenMP 477

Deux petits exemples


Deuxième exemple :
void foo ( int n , int nb_threads ) {
printf ( " foo ( %d , % d )\ n " , n , nb_threads );

omp_set_num_threads ( nb_threads );

# pragma omp parallel


# pragma omp for
for ( int i = 0; i < n ; i ++ ) {
int id = omp_get_thread_num ();
printf ( " i = % d : id = % d \ n " , i , id );
}
}

Quel est l’effet d’un appel à foo( 5, 3 )?


Exercice 10.2: Effet d’un appel à foo( 5, 3 ).
OpenMP 478

– Une forme utilisée fréquemment est la suivante :


# pragma omp parallel
# pragma omp for
for ( ... )

Dans ce cas, on peut utiliser l’abréviation suivante :


# pragma omp parallel for
for ( ... )

– Une boucle utilisée pour effectuer une réduction peut être codée en spé-
cifiant une opération et une variable de réduction — voir plus loin pour
des exemples :
# pragma omp for reduction( <op>: var )

Les opérations possibles <op> sont les suivantes : +, *, -, &, |, &&, ||,^,
min, max.
Remarque : Depuis la version 4.0 (juillet 2013), il est aussi possible
d’utiliser une opération de réduction définie par le programmeur :
[In version 4.0,] The reduction clause [. . . ] was extended and
the declare reduction construct [. . . ] was added to support user
defined reductions.
OpenMP 479

10.3 Autres directives de synchronisation


• Région critique : l’instruction qui suit est exécutée de façon exclusive, donc
un seul thread à la fois peut l’exécuter (exclusion mutuelle dans une section
critique) — cette instruction peut aussi être une instruction complexe, i.e., un
bloc.
# pragma omp critical

Il est aussi possible de nommer la région critique, lorsqu’un programme en


contient plusieurs pouvant être actives en même temps :

# pragma omp critical( <identifiant> )

• Exécution unique où c’est le premier thread arrivé, et seulement lui, qui exécute
l’instruction qui suit :
# pragma omp single

• Exécution unique où c’est le thread maître — celui qui a lancé l’équipe de


threads, qui possède le numéro 0 —, et seulement lui, qui exécute l’instruction
qui suit :
# pragma omp master

• Barrière explicite de synchronisation, ne pouvant pas être utilisée à l’intérieur


d’une région de partage de travail :
# pragma omp barrier

• Accès atomique de lecture/mise à jour/écriture d’une variable simple avec


un opérateur binaire approprié :

# pragma omp atomic


x = x <op> <expr>

Les valeurs possibles pour <op> sont les opérateurs binaires de base : +, *,
-, &, |, &&, ||, ^, <<, >>
Note : La différence entre une section critique (avec critical) et une instruc-
tion atomique (avec atomic) c’est que dans le premier cas, on peut indiquer
un bloc arbitraire d’instructions, alors que dans le deuxième cas on ne peut
indiquer qu’une seule instruction de manipulation d’une variable simple. De
telles opérations atomiques sont généralement mises en oeuvre sans utilisation
de verrous, donc de façon beaucoup plus efficace. On verra ultérieurement des
exemples en Java.
OpenMP 480

• Instructions explicites de synchronisation. Contrairement aux verrous im-


plicites (pragma critical), ces instructions peuvent être utilisées de façon
complètement arbitraire (donc de façon non structurée, non balancée).
omp_init_lock ( omp_loc_t * lock )
omp_set_lock ( omp_loc_t * lock )
omp_unset_lock ( omp_loc_t * lock )
omp_test_lock ( omp_loc_t * lock )
omp_init_destroy ( omp_loc_t * lock )

Il existe aussi des variantes pour verrous imbriqués (nest_lock).


OpenMP 481

10.4 Clauses de distribution du travail entre les threads


dans les boucles
Les clauses de distribution des boucles for permettent de spécifier de quelle façon les
diverses itérations d’une boucle for sont réparties entre les divers threads. Il existe
quatre variantes, qu’on indique toutes à la suite de «#pragma omp for ...» :
schedule( static [,chunk] )

schedule( dynamic [,chunk] )

schedule( guided [,chunk] )

schedule( runtime )

schedule( auto )

• Statique : répartition statique entre les threads. Si la valeur chunk est absente,
alors les différentes itérations sont réparties de façon relativement uniforme
entre les différents threads — itérations adjacentes. Si la valeur chunk est
présente, alors les différentes itérations sont réparties par groupe de chunk en-
tre les threads, conduisant ainsi à une répartition cyclique — appelé interleaved
en OpenMP.
• Dynamique : répartition dynamique entre les threads. Chaque thread obtient
chunk itérations à traiter. Si la valeur chunk est absente, alors si elle considérée
égale à 1.
• guided (guidée) : répartition dynamique entre les threads, mais avec un com-
portement qui varie en cours d’exécution quant au nombre d’itérations allouées
à chaque fois. La première fois, chaque thread obtient un certain nombre
d’itérations à traiter. Puis, à chaque fois subséquente, le nombre qui lui est
attribué diminue de façon exponentielle — i.e., que le nombre obtenu est un
certain pourcentage (qui dépend de l’implémentation) du nombre obtenu la fois
précédente. Toutefois, la diminution du nombre d’itérations cesse lorsqu’on
atteint la valeur de chunk, qui est de 1 si rien n’est indiqué.
Le terme «guided » vient de «guided self-scheduling».

Similar to dynamic scheduling, but the chunk size starts off large and
decreases to better handle load imbalance between iterations. The optional
chunk parameter specifies the minimum size chunk to use.
https: // software. intel. com/ en-us/ articles/ openmp-loop-scheduling
OpenMP 482

• runtime (à l’exécution) : la stratégie, l’une des trois précédentes, est définie


par l’intermédiaire de la variable d’environnement OMP_SCHEDULE.

• auto : la décision est laissée au compilateur!

10.5 Création dynamique de tâches


Depuis la version 3.0, il est possible de créer dynamiquement des tâches, et ce avec
task et taskwait. Un exemple simple, le calcul parallèle récursif du nième nombre
de Fibonacci, est présenté plus loin.
# pragma omp task
r_foo1 = foo1 ( ... ); // Tache pour foo1
# pragma omp task
r_foo2 = foo2 ( ... ); // Tache pour foo2
.
.
.
# pragma omp taskwait
r = bar ( r_foo1 ,... , r_foo2 ) // Attente taches
OpenMP 483

10.6 Exemples
Les exemples qui suivent sont tirés et adaptés (simplifiés!) de deux articles provenant
du site Web de Sun/Oracle :

• «Introducing OpenMP: A Portable, Parallel Programming API for Shared


Memory Multiprocessors»2
3
• «OpenMP Support in Sun Studio Compilers and Tools»

10.6.1 Directive parallel


int main ( void )
{
omp_set_dynamic (0);
omp_set_num_threads (10);

# pragma omp parallel


{
/* Obtain thread ID . */
int tid = omp_get_thread_num ();

/* Print thread ID . */
printf ( " Hello World from thread = % d \ n " , tid );
}
}

2
http://developers.sun.com/solaris/articles/omp-intro.html
3
http://developers.sun.com/solaris/articles/studio_openmp.html
OpenMP 484

10.6.2 Directive for


int main ( void )
{
float a [ N ] , b [ N ] , c [ N ];

omp_set_dynamic (0);
omp_set_num_threads (20);

/* Initialize arrays a and b . */


for ( int i = 0; i < N ; i ++ ) {
a [ i ] = i * 1.0;
b [ i ] = i * 2.0;
}

/* Compute values of array c in parallel . */


# pragma omp parallel for
for ( int i = 0; i < N ; i ++ ) {
c [ i ] = a [ i ] + b [ i ];
}
printf ( " % f \ n " , c [10]);
}
OpenMP 485

10.6.3 Directive sections


int square ( int n ) { return n * n ; }

int main ( void )


{
int x , y , z , xs , ys , zs ;
omp_set_dynamic (0);
omp_set_num_threads (3);
x = 2; y = 3; z = 5;

# pragma omp parallel sections


{
# pragma omp section
{ xs = square ( x );
printf ( " id = %d , xs = % d \ n " , omp_get_thread_num () , xs );
}
# pragma omp section
{ ys = square ( y );
printf ( " id = %d , ys = % d \ n " , omp_get_thread_num () , ys );
}
# pragma omp section
{ zs = square ( z );
printf ( " id = %d , zs = % d \ n " , omp_get_thread_num () , zs );
}
}
}
OpenMP 486

10.6.4 Directive for avec réduction


Version Séquentielle

int sum = 0;

for ( int i = 0; i < n ; i ++ ) {


sum += s om e _c om pl e x_ lo ng _ fu nc ti o n ( a [ i ]);
}
Avec section critique

int sum = 0;

# pragma omp parallel for shared ( sum , a , n )


for ( int i = 0; i < n ; i ++ ) {
int value = s om e_ co m pl ex _l o ng _f un c ti on ( a [ i ]);

# pragma omp critical


sum += value ;
}

Est-ce que cette solution sera efficace?


Exercice 10.3: Utilisation de critical.

Avec opération atomique


Note : Puisque l’opération qui doit être exécutée de façon exclusive est une instruc-
tion simple d’affectation avec ajout, on peut utiliser aussi une directive atomic, plus
efficace qu’un accès à un verrou requis dans une clause critical.
int sum = 0;

# pragma omp parallel for shared ( sum , a , n )


for ( int i = 0; i < n ; i ++ ) {
int value = s om e_ co m pl ex _l o ng _f un c ti on ( a [ i ]);

# pragma omp atomic


sum += value ;
}

Est-ce que cette solution sera efficace?


Exercice 10.4: Utilisation d’atomic.
OpenMP 487

Autre version équivalente (la plus simple et courte)


Cette dernière version est la plus simple, puisque i est déclarée localement et qu’on
utilise les propriétés implicites pour éviter d’introduire la clause shared.
int sum = 0;

# pragma omp parallel for reduction (+: sum )


for ( int i = 0; i < n ; i ++ ) {
sum += s om e _c om pl e x_ lo ng _ fu nc ti o n ( a [ i ]);
}

Est-ce que cette solution sera efficace?


Exercice 10.5: Utilisation de reduction.
OpenMP 488

10.6.5 Directives task et taskwait


int fibo ( int n )
{
if ( n <= 1 ) {
return 1;
} else {
int r1 , r2 ;

# pragma omp task shared ( r1 )


// Le shared est obligatoire !
// ( les regles sont differentes pour cette directive )
r1 = fibo ( n -1 );

# pragma omp task shared ( r2 )


r2 = fibo ( n -2 );

# pragma omp taskwait


return r1 + r2 ;
}
}

int main ( int argc , char * argv [] ) {


assert ( argc >= 3 );
int n = atoi ( argv [1] );
int nb_threads = atoi ( argv [2] );

omp_set_dynamic (0);
omp_set_num_threads ( nb_threads );

# pragma omp parallel


{
# pragma omp single
printf ( " fibo (% d ) = % d \ n " , n , fibo ( n ) );
}
return ( 0 );
}

Que se passe-t-il si on omet la clause «#pragma omp single»?


Exercice 10.6: Utilisation single.
OpenMP 489

10.A Modèles avec fork/join ou threads explicites


vs. implicites
Le modèle fork/join est celui utilisé dans plusieurs de langages de programmation,
par exemple, Java, C/Pthreads (C avec threads Posix). La différence entre ces
langages et OpenMP est essentiellement le fait qu’en C ou Java, tant les instructions
fork et join (ou ce qui en tient lieu) sont explicites, alors qu’en OpenMP ces
instructions sont implicites (particulièrement le join).
Une autre différence est qu’en Java, C/Pthreads (ainsi que MPD), un thread cor-
respond à l’activation d’une fonction explicite qui représente le code et le contexte
du thread. Par contre, ce n’est pas le cas en OpenMP : (presque) n’importe quel
bout de code peut correpondre au code du thread.
Comparaisons OpenMP, PRuby, Ruby, Java vs. C :

• Le fork est généralement explicite — pour lancer le thread , même si l’instruction


peut ne pas être fork!

• Le join peut être explicite ou implicite

• Le code du thread peut être une λ-expression (PRuby/Ruby, Java), une fonction
explicite (C) ou du code arbitraire (OpenMP)

Note : Certains de ces exemples seront présentés en MPD, un langage qui utilise
une instruction de type cobegin/coend pour lancer des threads. Bien que nous ne
verrons pas ce langage dans le cours, les exemples devraient quand même pouvoir
être compris — et être facilement traduits en Ruby/PRuby.
OpenMP 490

Exemple
Soit le code séquentiel suivant :

def f1 ( x ); ...; end


def f2 ( x ); ...; end

def foo ( k )
f2 ( f1 ( k )) + f1 ( k )
end

r = Array . new ( N )

r . each_index do | k |
r [ k ] = foo ( k )
end
On veut paralléliser ce code. Il s’agit d’un problème embarrassingly parallel, et
on veut définir une solution à granularité (très) fine.
OpenMP 491

MPD
(Avec thread «explicite», join implicite.)

procedure foo( int k ) returns int r


{
r = f2(f1(k)) + f1(k)
}

int r[N]

co [k = 0 to N-1] # Co-begin/co-end.
r[k] = foo(k)
oc

⇒ Il faut introduire une procédure auxiliaire pour le thread /


PRuby
(Avec thread explicite (λ-expression), join implicite.)
r = Array . new ( N )

PRuby . pcall ( 0... N ,


lambda do | k |
r [ k ] = f2 ( f1 ( k )) + f1 ( k )
end
)

OpenMP/C
(Avec thread implicite, join implicite.)
omp_set_num_threads ( N );

int * r = ( int *) malloc ( N * sizeof ( int ));

# pragma omp parallel for schedule ( static, 1 )


for ( int k = 0; k < N ; k ++ ) {
r [ k ] = f2 ( f1 ( k )) + f1 ( k );
}
OpenMP 492

PRuby (bis)
(Avec thread explicite (bloc), join explicite.)
r = Array . new ( N )

futures = (0... N ). map do | k |


PRuby . future { f2 ( f1 ( k )) + f1 ( k ) }
end

r . each_index do | k |
r [ k ] = futures [ k ]. value
end
OpenMP/C
(Avec thread implicite, join implicite.)
omp_set_num_threads ( N );

int * r = ( int *) malloc ( N * sizeof ( int ));

# pragma omp parallel for schedule ( static, 1 )


for ( int k = 0; k < N ; k ++ ) {
r [ k ] = f2 ( f1 ( k )) + f1 ( k );
}
OpenMP 493

PRuby (ter)
(Avec thread implicite, join implicite.)
PRuby . nb_threads = N

r = Array . new ( N )

r . peach_index ( static : 1 ) do | k |
r [ k ] = f2 ( f1 ( k )) + f1 ( k )
end

OpenMP/C
(Avec thread implicite, join implicite.)
omp_set_num_threads ( N );

int * r = ( int *) malloc ( N * sizeof ( int ));

# pragma omp parallel for schedule ( static, 1 )


for ( int k = 0; k < N ; k ++ ) {
r [ k ] = f2 ( f1 ( k )) + f1 ( k );
}
OpenMP 494

Java (avec Future et lambda-expression)


(Avec thread explicite, join explicite.)
ExecutorService pool
= Executors . newCachedThreadPool ();

int [] r = new int [ N ];

Future < Integer >[] fs = new Future [ N ];


for ( int k = 0; k < N ; k ++ ) {
final int kf = k ;
fs [ k ] = pool . submit (
() -> f2 ( f1 ( kf )) + f1 ( kf )
);
}

for ( int k = 0; k < N ; k ++ ) {


try { r [ k ] = fs [ k ]. get (); }
catch ( Exception e ){...};
}

pool . shutdown ();


OpenMP 495

C/Pthreads
(Avec thread explicite, join explicite.)
void * foo ( void * arg )
{
int k = ( int ) arg ;
int resultat = f2 ( f1 ( k )) + f1 ( k );

pthread_exit ( ( void *) resultat );


}

pthread_attr_t attr ;
pthread_attr_init (& attr );
pthread_attr_setscope (& attr , PTHREAD_SCOPE_SYSTEM );

int * r = ( int *) malloc ( N * sizeof ( int ));

pthread_t * trIds
= ( pthread_t *) malloc ( N * sizeof ( pthread_t ));
for ( int k = 0; k < N ; k ++ ) {
pthread_create ( & trIds [ k ] , & attr ,
foo , ( void *) k );
}

for ( int k = 0; k < N ; k ++ ) {


pthread_join ( trIds [ k ] , ( void *) & r [ k ] );
}

Langage fork join Code du thread


PRuby Explicite Implicite lambda/bloc
Ruby Explicite Explicite bloc
Java Explicite Explicite lambda
C Explicite Explicite fonction
OpenMP Région Implicite code arbitraire
OpenMP 496

10.B Exercices
10.B.1 Traitement d’une liste chainée
Soit un programme C contenant le code suivant :
typedef struct Noeud {
struct Noeud * suivant ;
long valeur ;
} Noeud ;

void foo ( Noeud * pt ) {


// On traite le noeud et sa valeur .
...
// On imprime une trace .
printf ( " foo ( % p ): valeur = % d \ n " , pt , pt - > valeur );
}

...

// tete = reference vers une liste avec 6 elements .


for ( Noeud * pt = tete ; pt != NULL ; pt = pt - > suivant ) {
foo ( pt );
}

L’exécution produit alors le résultat suivant :


foo ( 0 xe900d0 ): valeur = 5
foo ( 0 xe900b0 ): valeur = 4
foo ( 0 xe90090 ): valeur = 3
foo ( 0 xe90070 ): valeur = 2
foo ( 0 xe90050 ): valeur = 1
foo ( 0 xe90030 ): valeur = 0
OpenMP 497

On veut paralléliser la boucle for. Quels résultats produiront chacune des séries
d’annotations ci-bas.

1. # pragma omp parallel for


for ( Noeud * pt = tete ; pt != NULL ; pt = pt - > suivant ) {
foo ( pt );
}

2. # pragma omp parallel


for ( Noeud * pt = tete ; pt != NULL ; pt = pt - > suivant ) {
# pragma omp task
foo ( pt );
# pragma omp taskwait
}

3. # pragma omp parallel


# pragma omp single
for ( Noeud * pt = tete ; pt != NULL ; pt = pt - > suivant ) {
# pragma omp task
foo ( pt );
# pragma omp taskwait
}

4. # pragma omp parallel


# pragma omp single
for ( Noeud * pt = tete ; pt != NULL ; pt = pt - > suivant ) {
# pragma omp task
foo ( pt );
}
# pragma omp taskwait

Exercice 10.7: Parallélisation du traitement des éléments d’une liste chainée.


498
Programmation parallèle OpenCL 499

Chapitre 11

Programmation parallèle de GPU


avec OpenCL/C

11.0 Brefs rappels sur l’évolution des architectures


parallèles

Figure 11.1: Les premières architectures parallèles étaient des machines SIMD (Sin-
gle Instruction, Multiple Data), donc avec une unique unité de contrôle et plusieurs
unités de calcul (PE = processing element).
Programmation parallèle OpenCL 500

Figure 11.2: Par la suite, on a vu apparaître les multi-processeurs = groupe de


processeurs connectés par un bus. Le bus est un goulot d’étranglement, puisqu’il
ne permet qu’une seule transaction à la fois, ce qui limite le nombre de processeurs
pouvant être utilisés/connectés.

Figure 11.3: Ensuite sont apparus les premiers multi-ordinateurs = groupe de pro-
cesseurs connectés par un réseau. L’utilisation d’un réseau permet plusieurs trans-
actions à la fois, ce qui permet d’avoir un plus grand nombre de processeurs.
Programmation parallèle OpenCL 501

Figure 11.4: Puis sont apparues les architectures parallèles hybrides — les grappes
de multi-processeurs.
Programmation parallèle OpenCL 502

Figure 11.5: Ensuite, avec l’apparition des processeurs à coeurs multiples, on a


maintenant des grappes de multi-processeurs à coeurs multiples.
Programmation parallèle OpenCL 503

Figure 11.6: Plus récemment, on a vu l’essor des GPUs.


(Source : http://www.nvidia.com/object/GPU_Computing.html)
Programmation parallèle OpenCL 504

11.1 Introduction
11.1.1 Que signifie l’acronyme «GPGPU»?
General-purpose computing on graphics processing units (GPGPU, rarely
GPGP or GP2 U) is the use of a graphics processing unit (GPU), which
typically handles computation only for computer graphics, to perform
computation in applications traditionally handled by the central process-
ing unit (CPU).

Source : https: // en. wikipedia. org/ wiki/ General-purpose_ computing_


on_ graphics_ processing_ units

Donc :

• GPU = Matériel

• GPGPU = Programmation du matériel

11.1.2 Qu’est-ce qu’OpenCL?


OpenCL (Open Computing Language) is “a framework suited for parallel
programming of heterogeneous systems”. The framework includes the
OpenCL C language as well as the compiler and the runtime environment
required to run the code written in OpenCL C.

Source : http: // www. fixstars. com/ en/ opencl/ book/ OpenCLProgrammingBook/


what-is-opencl/
Programmation parallèle OpenCL 505

OpenCL is standardized by the Khronos Group [which] consists of mem-


bers from companies like AMD, Apple, IBM, Intel, NVIDIA, Texas In-
struments, Sony, Toshiba[, . . . ]. The goal [. . . ] is ultimately to be able to
program any combination of processors, such as CPU, GPU, Cell/B.E.,
DSP, etc. using one language.

Source : http: // www. fixstars. com/ en/ opencl/ book/ OpenCLProgrammingBook/


what-is-opencl/
Programmation parallèle OpenCL 506

L’ancêtre d’OpenCL = CUDA :

CUDA, which stands for Compute Unified Device Architecture, is a par-


allel computing platform and application programming interface (API)
model created by NVIDIA. It allows software developers to use a CUDA-
enabled graphics processing unit (GPU) for general purpose processing —
an approach known as GPGPU. The CUDA platform is a software layer
that gives direct access to the GPU’s virtual instruction set and parallel
computational elements.

Source : https: // en. wikipedia. org/ wiki/ CUDA


Programmation parallèle OpenCL 507

11.1.3 Architecture typique d’un GPU


Un CPU moderne peut avoir «plusieurs» coeurs (e.g., 22 , 23 ) alors qu’un GPU peut
en avoir «un très grand nombre» (e.g., 103 , 104 , etc.) — mais ces derniers sont
beaucoup plus simples et moins puissants.

Source : http://www.nvidia.com/object/GPU_Computing.html

Multicore CPUs and manycore GPUs have emerged and gradually dom-
inated state-of-the-art high-performance computing. Although contem-
porary CPUs and GPUs are manufactured using the same semiconduc-
tor technology, the computational performance of GPUs increases more
rapidly than that of CPUs. Divergent design choices drive them into
devices of different capabilities given the same order of transistor count.
CPUs are optimized for high-performance, task-parallel work-
loads since more transistors are dedicated to control logics such as branch
prediction and out-of-order execution in each processing element. GPUs
are optimized for high-performance data-parallel workloads since
more transistors are dedicated to arithmetic logics such as floating-point
calculation and transcendental function in each processing element.
«A Closer Look at GPGPU » Hu & Che, ACM Computing Surveys, 2016.
Programmation parallèle OpenCL 508

11.2 Modèle d’exécution et de programmation d’OpenCL


Avec OpenCL, il faut distinguer entre le Host et les Compute devices :

• Host = CPU ordinaire

• Compute devices = GPU, DSP, FPGA, etc.

Source : https://www.khronos.org/assets/uploads/developers/library/overview/opencl_
overview.pdf
Les compute devices sont utilisés comme accélérateurs!
Programmation parallèle OpenCL 509

Avec OpenCL, on doit tenir compte des différents niveaux de mémoire :

Source : https://www.khronos.org/assets/uploads/developers/library/overview/opencl_
overview.pdf
Programmation parallèle OpenCL 510

C’est le programmeur qui est responsable des transferts de données entre le CPU
et le GPU!
Les différents niveaux de mémoire :

• Mémoire du host

• Mémoire des compute devices :

1. Global Memory
Memory that can be read from all work items. It is physically the device’s
main memory.
2. Constant Memory
Also memory that can be read from all work items. It is physically the
device’s main memory, but can be used more efficiently than global mem-
ory if the compute units contain hardware to support constant memory
cache. The cost memory is set and written by the host.
3. Local Memory
Memory that can be read from work items within a work group. It is
physically the shared memory on each compute units.
4. Private Memory.
Memory that can only be used within each work item. It is physically the
registers used by each processing element.

Source : http://www.fixstars.com/en/opencl/book/OpenCLProgrammingBook/applicable-platforms/
Programmation parallèle OpenCL 511

Les GPUs modernes (style Nvidia) utilisent une approche d’exécution SIMT
pour les processing elements de bas niveau :

= Single Instruction, Multiple Threads

⇒ Multiple independent threads execute concurrently using a single


instruction.

Source : https://www.irisa.fr/alf/downloads/collange/talks/sisdmt_scollange.pdf
Advantages/Disadvantages:

+ Reduce instruction fetching overhead

- Control-flow done using «masking», i.e., some threads may not do any work

Étant donné l’architecture décrite ci-haut, quel vous semble le modèle de program-
mation (le «patron d’algorithme parallèle») le plus naturel pour un GPU?
Exercice 11.1: Patron de programmation pour GPU.
Programmation parallèle OpenCL 512

11.2.1 Modèle d’exécution OpenCL


Le code exécuté sur les processing elements des compute devices est appelé un kernel.

Ces kernels sont écrits en OpenCL C.

Source : http://www.ks.uiuc.edu/Research/gpu/files/upcrc_opencl_lec1.pdf
Programmation parallèle OpenCL 513

L’exécution d’un kernel se fait pour chaque work item, lesquels sont regroupés
en work groups.

Source : https://software.intel.com/sites/landingpage/opencl/optimization-guide/Basic_
Concepts.htm

«An OpenCL device has one or more compute units.


A work-group executes on a single compute unit.
A compute unit is composed of one or more processing elements and local
memory.
A work-item is executed [. . . ] as part of a work-group executing on a
compute unit. A work-item is distinguished from other executions within
the collection by its global ID and local ID. »

Source : https://software.intel.com/sites/landingpage/opencl/optimization-guide/Basic_
Concepts.htm
Programmation parallèle OpenCL 514

L’exécution d’un kernel se fait en ajoutant une commande à la queue d’exécution


d’un device.

Source : http://www.ks.uiuc.edu/Research/gpu/files/upcrc_opencl_lec1.pdf
Programmation parallèle OpenCL 515

Mais auparavant, il faut créer un contexte d’exécution :

• Devices à utiliser.

• Espace mémoire.

• Queue de commandes.

Source : http://www.ks.uiuc.edu/Research/gpu/files/upcrc_opencl_lec1.pdf
Programmation parallèle OpenCL 516

11.2.2 Processus de compilation d’un kernel OpenCL/C


La compilation d’un kernel peut se faire directement à partir du programme prin-
cipal sur le host.

Source : http://www.fixstars.com/en/opencl/book/OpenCLProgrammingBook/online-offline-compilation

Il y a certaines restrictions sur le code C permis dans un kernel :

• Pas de pointeurs vers des fonctions.

• Pas de pointeurs vers des pointeurs utilisés en arguments.

• Pas de tableaux ou structures de taille variable.

• Pas de bit fields.

• Pas de récursion.

• Etc.
Programmation parallèle OpenCL 517

11.3 Exemple : Somme de deux tableaux


Programme séquentiel en C
void somme_tableaux ( const float a [] ,
const float b [] ,
float c [] ,
int N )
{

for ( int i = 0; i < N ; i ++ ) {


c [ i ] = a [ i ] + b [ i ];
}
}

Programme parallèle en OpenMP/C


void somme_tableaux ( const float a [] ,
const float b [] ,
float c [] ,
int N )
{
# pragma omp parallel for
for ( int i = 0; i < N ; i ++ ) {
c [ i ] = a [ i ] + b [ i ];
}
}
Programmation parallèle OpenCL 518

Programme parallèle en OpenCL/C


Le programme OpenCL/C qui suit vise à calculer, sur un GPU, la somme de deux
tableaux. Tout d’abord, voici le kernel , défini dans le fichier "somme_tableaux.cl".

// //// / / / // / / / / // / / / / // / / / / // /
// FICHIER somme_tableaux . cl
// //// / / / // / / / / // / / / / // / / / / // /

__kernel void somme_tableaux ( __global const float * a ,


__global const float * b ,
__global float * c )
{
int i = get_global_id (0);

c [ i ] = a [ i ] + b [ i ];
}

• Un kernel définit le travail à faire pour chaque work item.

• Chaque work item est identifié par un numéro unique — get_global_id(0)


pour un groupe unidimensionel.

• Un kernel est une fonction strictement séquentielle.

void somme_tableaux ( const float a [] ,


const float b [] ,
float c [] ,
int N )
{
# pragma omp parallel for
for ( int i = 0; i < N ; i ++ ) {
c[i] = a[i] + b[i]; / / L e k e r n e l !
}
}
Programmation parallèle OpenCL 519

Analogie (exprimée en PRuby) : Code utilisant du parallélisme de boucles. . . pour


réaliser du parallélisme de données

# Pour simplifier, on suppose que les __global


# sont referencees via des variables non-locales.

a = Array . new ( N ) { ... }


b = Array . new ( N ) { ... }
c = Array . new ( N )

# Le kernel = code execute sur le device .


kernel_somme_tableaux = lambda do | global_id |
i = global_id [0]
c[i] = a[i] + b[i]
end

# Dispatch du kernel : fait sur le device .


def run_kernel ( kernel , nb_dimensions ,
nb_taches , * args )
index = index_space ( nb_dimensions , nb_taches )
index . peach(nb_threads: index.size) do | global_id |
kernel . call ( global_id , * args )
end
end

# Appel du kernel : fait sur le host .


run_kernel ( kernel_somme_tableaux , 1 , [ N ] )
Programmation parallèle OpenCL 520

L’ensemble des numéros d’identification des différents work items est l’index
space.
Exemples de résultats produits par index_space (version Ruby) :
index_space ( 1 , [3] )
== [[0] , [1] , [2]]

index_space ( 2 , [2 , 3] )
== [[0 , 0] , [0 , 1] , [0 , 2] ,
[1 , 0] , [1 , 1] , [1 , 2]]

index_space ( 3 , [3 , 2 , 2] )
== [[0 , 0 , 0] , [0 , 0 , 1] , [0 , 1 , 0] , [0 , 1 , 1] ,
[1 , 0 , 0] , [1 , 0 , 1] , [1 , 1 , 0] , [1 , 1 , 1] ,
[2 , 0 , 0] , [2 , 0 , 1] , [2 , 1 , 0] , [2 , 1 , 1]]

• Si le nombre de work items est plus grand que le nombre de coeurs et le


programmeur ne spécifie pas la décomposition en groupes, alors OpenCL gére
cette décomposition.
Par exemple :100 000 items à traiter avec 1000 coeurs ⇒ 100 «passes» — dans
un ordre arbitraire!
Programmation parallèle OpenCL 521

// //// / / / // / / / / // / / / / // / / / / // /
// FICHIER somme_tableaux . c
// //// / / / // / / / / // / / / / // / / / / // /

/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *

Cet exemple vise a illustrer ... la " complexite " ( sic ) de la


programmation des GPUs avec OpenCL !

Soit deux tableaux a et b de taille N . On desire simplement


faire la somme de ces deux tableaux a et b et mettre
le resultat dans c .

Donc , on desire paralleliser la boucle suivante :


for ( int i = 0; i < N ; i ++ ) {
c [ i ] = a [ i ] + b [ i ];
}

Remarque : Cet exemple est inspire ( mais grandement modifie et


simplifie !) d ’ un exemple presente dans " Seven Concurrency Models in
Seven Weeks " , de Paul Butcher , The Pragmatic Bookshelf , 2014.
Voir l ’ URI suivant pour des informations sur ce livre :

http :// www . pragmaticprogrammer . com / titles / pb7con

Notamment , la presente version est simplifiee en ce qu ’ elle


n ’ effectue aucune verification du statut ( i . e . , du code d ’ erreur )
retourne par un appel a la bibliotheque OpenCL .

Plus precisement , chaque appel de fonction a la bibliotheque peut


retourner une valeur de success ou d ’ erreur , soit par un resultat de
la fonction , soit par une variable resultat . Si cette valeur est
differente de CL_SUCCESS , alors c ’ est qu ’ il y a eu une erreur , et
cette erreur est indique par le code de la valeur de retour .
( Lorsque le statut est retourne par une variable resultat ,
on peut ne pas le recevoir en specifiant " NULL " comme adresse
pour le resultat .)

*/

//
Programmation parallèle OpenCL 522

# ifdef __APPLE__
# include < OpenCL / cl .h >
# else
# include < CL / cl .h >
# endif

# include < stdio .h >


# include < math .h >
# include < time .h >
# include < assert .h >

# include < sys / stat .h >

//
Programmation parallèle OpenCL 523

char * lire_fichier_source ( const char * nom_fichier )


{
// On determine la taille du fichier .
struct stat st ;
stat ( nom_fichier , & st );

// On ouvre et on lit le contenu du fichier .


FILE * fich = fopen ( nom_fichier , " r " );
assert ( fich != NULL );

char * programme = ( char *) malloc ( st . st_size + 1);


fread ( programme , sizeof ( char ) , st . st_size , fich );
fclose ( fich );

// La chaine doit se terminer par ASCII 0.


programme [ st . st_size ] = ’ \0 ’;

return programme ;
}

//
Programmation parallèle OpenCL 524

# define PRECISION 0.001

void initialiser ( float a [] , size_t N )


{
for ( int i = 0; i < N ; i ++ ) {
a [ i ] = 1.0;
}
}

//
Programmation parallèle OpenCL 525

int main ( int argc , char * argv [] )


{
// On lit l ’ argument du programme , qui indique la taille
// des tableaux a generer et additionner .
assert ( argc >= 2 );
size_t N = atoi ( argv [1] );

// On obtient un * device * de type GPU .


cl_platform_id plateforme ;
cl_device_id device ;
clGetPlatformIDs ( 1 , & plateforme , NULL );
clGetDeviceIDs ( plateforme , CL_DEVICE_TYPE_GPU , 1 ,
& device , NULL );

// On cree un contexte d ’ execution et une queue d ’ execution .


// C ’ est par l ’ intermediaire d ’ un tel contexte qu ’ on peut
// ensuite executer des kernels , et c ’ est la queue qui permet
// de transmettre au contexte les requetes -- d ’ execution ,
// de lecture du resultat , etc .
cl_context contexte =
clCreateContext ( NULL , 1 , & device , NULL , NULL , NULL );
cl_command_queue queue =
clCreateCommandQueue ( contexte , device , 0 , NULL );

//
Programmation parallèle OpenCL 526

// On compile le code du kernel .


char * source = lire_fichier_source ( " somme_tableaux . cl " );
cl_program programme =
cl Cr ea teP ro gr amW it hS our ce ( contexte , 1 ,
( const char **)& source ,
NULL , NULL );
clBuildProgram ( programme , 0 , NULL , NULL , NULL , NULL );
cl_kernel kernel =
clCreateKernel ( programme , " somme_tableaux " , NULL );

// On alloue et initialise les tableaux a additionner .


float a [ N ] , b [ N ];
initialiser ( a , N );
initialiser ( b , N );

//
Programmation parallèle OpenCL 527

// On commence a mesurer le temps d ’ execution ( en ms ):


// preferable (+ " fair ") d ’ inclure les temps de transferts des
// donnees , parce que souvent significatifs .
clock_t debut = clock ();

//
Programmation parallèle OpenCL 528

// On cree les buffers pour transferer les donnees


// vers le GPU et ensuite recevoir le resultat .
cl_mem buffer_a =
clCreateBuffer ( contexte ,
CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR ,
sizeof ( float ) * N , a , NULL );

cl_mem buffer_b =
clCreateBuffer ( contexte ,
CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR ,
sizeof ( float ) * N , b , NULL );

cl_mem buffer_c =
clCreateBuffer ( contexte ,
CL_MEM_WRITE_ONLY , // Pour lire le resultat .
sizeof ( float ) * N , NULL , NULL );

// On specifie les arguments du kernel .


clSetKernelArg ( kernel , 0 , sizeof ( cl_mem ) , & buffer_a );
clSetKernelArg ( kernel , 1 , sizeof ( cl_mem ) , & buffer_b );
clSetKernelArg ( kernel , 2 , sizeof ( cl_mem ) , & buffer_c );

// On lance l ’ execution du kernel .


size_t nb_taches [] = { N };
clEnqueueNDRangeKernel ( queue , kernel , 1 , NULL , nb_taches ,
NULL , 0 , NULL , NULL );

// On obtient le resultat calcule : CL_TRUE = > bloquant !


float c [ N ];
clEnqueueReadBuffer ( queue , buffer_c , CL_TRUE , 0 ,
sizeof ( float ) * N , c , 0 , NULL , NULL );

// On arrete le chronometre et on indique le temps .


double temps_requis = clock () - debut ; // En ms !
printf ( " %20.0 f \ n " , temps_requis );

//
Programmation parallèle OpenCL 529

// On verifie que le resultat est correct


// ( a PRECISION pres ).
for ( int i = 0; i < N ; i ++ ) {
if ( fabsf ( c [ i ] - ( a [ i ] + b [ i ])) > PRECISION ) {
printf ( " *** Erreur : resultat incorrect [% d ]: % f + % f = % f \ n " ,
i , a [ i ] , b [ i ] , c [ i ] );
exit ( -1 );
}
}

//
Programmation parallèle OpenCL 530

// On libere les ressources .


clReleaseMemObject ( buffer_a );
clReleaseMemObject ( buffer_b );
clReleaseMemObject ( buffer_c );
clReleaseKernel ( kernel );
clReleaseProgram ( programme );
clReleaseCommandQueue ( queue );
clReleaseContext ( contexte );

return 0;
}
Programmation parallèle OpenCL 531

11.4 Exemple : Produit de deux matrices


Le programme OpenCL/C qui suit vise à calculer, sur un GPU, le produit de deux
matrices. Mais tout d’abord, voici le kernel – fichier "produit_matrices.cl".

// //// / / // / / / / / / / / / / / / / / / / / / / / /
// FICHIER produits_matrices . cl
// //// / / // / / / / / / / / / / / / / / / / / / / / /

__kernel void produit_matrices ( __global const float * a ,


__global const float * b ,
__global float * c ,
uint N )
{
int i = get_global_id (0);
int j = get_global_id (1);

float total = 0.0;


for ( int k = 0; k < N ; k ++ ) {
total += a [ i * N + k ] * b [ k * N + j ];
}
c [ i * N + j ] = total ;
}
Programmation parallèle OpenCL 532

Analogie (exprimée en PRuby) :

# Idem : __global via des variables non - locales .


a = Array . new ( N * N ) { ... }
b = Array . new ( N * N ) { ... }
c = Array . new ( N * N )

# Definition du " kernel " .


kernel _produit_ma trices = lambda do | global_id , n |
i = global_id [0]
j = global_id [1]
total = 0.0
(0... n ). each do | k |
total += a [ i * n + k ] * b [ k * n + j ]
end
c [ i * n + j ] = total
end

# Dispatch du " kernel " , fait sur le device .


def run_kernel ( kernel , nb_dimensions , nb_taches , * args )
index = index_space ( nb_dimensions , nb_taches )
index . peach ( nb_threads : index . size ) do | global_id |
kernel . call ( global_id , * args )
end
end

# Appel du kernel sur le host .


run_kernel(kernel_produit_matrices, 2, [N, N], N)
Programmation parallèle OpenCL 533

// //// / / // / / / / / / / / / / / / / / / / / / / / /
// FICHIER produits_matrices . c
// //// / / // / / / / / / / / / / / / / / / / / / / / /

# ifdef __APPLE__
# include < OpenCL / cl .h >
# else
# include < CL / cl .h >
# endif

# include < stdio .h >


# include < math .h >
# include < assert .h >

# include < sys / stat .h >

//
Programmation parallèle OpenCL 534

char * lire_fichier_source ( const char * nom_fichier )


{
// On determine la taille du fichier .
struct stat st ;
stat ( nom_fichier , & st );

// On ouvre et on lit le contenu du fichier .


FILE * fich = fopen ( nom_fichier , " r " );
assert ( fich != NULL );

char * programme = ( char *) malloc ( st . st_size + 1);


fread ( programme , sizeof ( char ) , st . st_size , fich );
fclose ( fich );

// La chaine doit se terminer par ASCII 0.


programme [ st . st_size ] = ’ \0 ’;

return programme ;
}

//
Programmation parallèle OpenCL 535

# define PRECISION 0.01

void initialiser ( float *a , size_t N )


{
int k = 0;
for ( int i = 0; i < N ; i ++) {
for ( int j = 0; j < N ; j ++) {
a [ i * N + j ] = 1;
}
}
}

void imprimer ( const char * nom , float *a , size_t N )


{
printf ( " % s ::\ n " , nom );
for ( int i = 0; i < N ; i ++) {
for ( int j = 0; j < N ; j ++) {
printf ( " % f " , a [ i * N + j ] );
}
printf ( " \ n " );
}
}

//
Programmation parallèle OpenCL 536

int main ( int argc , char * argv [] )


{
// On lit l ’ argument du programme , qui indique la taille
// des matrices a generer et multiplier .
assert ( argc >= 2 );
size_t N = atoi ( argv [1] );

// On obtient un * device * de type GPU .


cl_platform_id plateforme ;
clGetPlatformIDs ( 1 , & plateforme , NULL );

cl_device_id device ;
clGetDeviceIDs ( plateforme , CL_DEVICE_TYPE_GPU , 1 ,
& device , NULL );

// On cree un contexte et une queue d ’ execution .


cl_context contexte =
clCreateContext ( NULL , 1 , & device , NULL , NULL , NULL );
cl_command_queue queue =
clCreateCommandQueue ( contexte , device , 0 , NULL );

//
Programmation parallèle OpenCL 537

// On compile le code du kernel .


char * source = lire_fichier_source ( " produit_matrices . cl " );
cl_program programme =
cl Cr ea teP ro gr amW it hS our ce ( contexte , 1 ,
( const char **)& source , NULL , NULL );
free ( source );

// REMARQUE : Si le code du kernel contient une erreur


// de compilation , c ’ est clBuildProgram qui retourne
// un resultat indiquant une erreur . Donc , en general ,
// il est crucial de verifier le code retourne .
clBuildProgram ( programme , 0 , NULL , NULL , NULL , NULL );

cl_kernel kernel =
clCreateKernel ( programme , " produit_matrices " , NULL );

//
Programmation parallèle OpenCL 538

// On alloue et initialise les tableaux a additionner .


float a [ N * N ] , b [ N * N ];
initialiser ( a , N );
initialiser ( b , N );

//
Programmation parallèle OpenCL 539

// On cree les buffer pour les transferts vers le GPU .


cl_mem buffer_a =
clCreateBuffer ( contexte ,
CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR ,
sizeof ( float )* N *N , a , NULL );
cl_mem buffer_b =
clCreateBuffer ( contexte ,
CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR ,
sizeof ( float )* N *N , b , NULL );
cl_mem buffer_c =
clCreateBuffer ( contexte ,
CL_MEM_WRITE_ONLY ,
sizeof ( float )* N *N , NULL , NULL );

//
Programmation parallèle OpenCL 540

// On specifie les arguments du kernel et on lance son execution .


clSetKernelArg ( kernel , 0 , sizeof ( cl_mem ) , & buffer_a );
clSetKernelArg ( kernel , 1 , sizeof ( cl_mem ) , & buffer_b );
clSetKernelArg ( kernel , 2 , sizeof ( cl_mem ) , & buffer_c );
clSetKernelArg ( kernel , 3 , sizeof ( int ) , & N );

size_t nb_taches [] = {N , N };
clEnqueueNDRangeKernel ( queue , kernel , 2 , NULL , nb_taches ,
NULL , 0 , NULL , NULL );

//
Programmation parallèle OpenCL 541

// On va chercher le resultat : CL_TRUE = > bloquant !


float c [ N * N ];
clEnqueueReadBuffer ( queue , buffer_c , CL_TRUE , 0 ,
sizeof ( float )* N *N , c , 0 , NULL , NULL );

//
Programmation parallèle OpenCL 542

// On verifie que le resultat est correct ( PRECISION pres ).


for ( int i = 0; i < N ; i ++ ) {
for ( int j = 0; j < N ; j ++ ) {
if ( fabsf ( c [ i * N + j ] - (1.0 * N )) > PRECISION ) {
printf ( " *** Erreur : resultat incorrect [% d , % d ]: % f \ n " ,
i , j , c [ i * N + j ] );
exit ( -1 );
}
}
}

//
Programmation parallèle OpenCL 543

// On libere les ressources .


clReleaseMemObject ( buffer_a );
clReleaseMemObject ( buffer_b );
clReleaseMemObject ( buffer_c );
clReleaseKernel ( kernel );
clReleaseProgram ( programme );
clReleaseCommandQueue ( queue );
clReleaseContext ( contexte );

return 0;
}
Programmation parallèle OpenCL 544

N Prog. Seq. Prog. OpenCL


16 0.00 0.02
32 0.00 0.02
64 0.00 0.02
128 0.00 0.02
256 0.06 0.04
512 0.57 0.22
1024 15.37 2.27

Tableau 11.1: Temps d’exécution du produit de matrices sur Mac Book pour diverses
tailles de matrices (valeurs de N = nombre de lignes/colonnes). Le temps d’exécution
est celui obtenu avec time au niveau du shell pour lancer l’exécution du programme.

Figure 11.7: Graphe d’accélération sur MacBook pour la version parallèle OpenCL
par rapport à la version séquentielle pour N assez grand. Pour les N plus petits, le
temps séquentiel obtenu avec time est nul, donc ne peut être utilisé pour calculer
une accélération. Pour N = 2048, la version séquentielle ne peut pas s’exécuter par
manque de mémoire.
Programmation parallèle OpenCL 545

11.5 Exemple : Sommation des éléments d’un tableau


Programme séquentiel en C
void sommation_tableau ( const float a [] ,
float * result ,
int N )
{
float r = 0.0;

for ( int i = 0; i < N ; i ++ ) {


r += a [ i ];
}

* result = r ;
}
Programme parallèle en OpenMP/C
void sommation_tableau ( const float a [] ,
float * result ,
int N )
{
float r = 0.0;

# pragma omp parallel for reduction (+: r )


for ( int i = 0; i < N ; i ++ ) {
r += a [ i ];
}

* result = r ;
}
Programmation parallèle OpenCL 546

Programme parallèle en OpenCL/C


Algorithme SIMD pour la sommation
La figure ci-bas illustre — pour huit (8) éléments — comment fonctionne l’approche
SIMD de calcul de la somme des éléments d’un tableau (avec utilisation d’un tampon
auxiliaire)≈ approche récursive ascendante (bottom-up) — donc émule les calculs
effectués par un algorithme récursif, mais sans récursion et sans descendre dans
l’arbre des appels, uniquement en remontant les résultats.

Figure 11.8: Opérations effectuées par un algorithme SIMD pour la sommation des
éléments d’un tableau, en utilisant un tampon auxiliaire.

Pour un n arbitraire (puissance de 2), de façon plus générale, à la première


itération, on compare n2 paires ; à la deuxième itération, on en compare n4 ; à la k e
itération, on en compare 2nk , jusqu’à la dernière itération où on compare une seule
paire. Dans chaque cas, on déplace le minimum — le «gagnant» de la comparaison
– dans la case gauche, i.e., cella vec le plus petit index.
Le principe de fonctionnement est donc semblable à celui d’un tournoi.
Programmation parallèle OpenCL 547

Programme séquentiel en C
void sommation_tableau ( const float a [] ,
float * result ,
int N )
{
// On alloue le tampon et on copie le tableau .
float * tampon =
( float *) malloc ( N * sizeof ( float ) );
for ( int i = 0; i < N ; i ++ )
tampon [ i ] = a [ i ];

// On effectue la reduction sur le tampon .


for ( int j = N / 2; j > 0; j /= 2 ) {
for ( int i = 0; i < N ; i ++ ) {
if ( i < j ) {
tampon [ i ] += tampon [ i + j ];
}
}
}

// On retourne le resultat et on nettoye .


* result = tampon [0];

free ( tampon );
}
Programmation parallèle OpenCL 548

Programme séquentiel en C (bis)


void sommation_tableau ( const float a [] ,
float * result ,
int N )
{
// On alloue le tampon et on copie le tableau .
float * tampon =
( float *) malloc ( N * sizeof ( float ) );
for ( int i = 0; i < N ; i ++ )
tampon [ i ] = a [ i ];

// On effectue la reduction sur le tampon .


for ( int i = 0; i < N ; i ++ ) {
for ( int j = N / 2; j > 0; j /= 2 ) {
if ( i < j ) {
tampon [ i ] += tampon [ i + j ];
}
}
}

// On retourne le resultat et on nettoye .


* result = tampon [0];

free ( tampon );
}
Programmation parallèle OpenCL 549

La mise en oeuvre en OpenCL


L’extrait de programme OpenCL/C qui sert à calculer, sur un GPU, la somme
des éléments d’un tableau. Mais tout d’abord, on présente le kernel – fichier
"sommation_tableau.cl".
L’approche utilisée est la même que celle utilisée dans le laboratoire #9 pour
calculer le minimum d’un tableau avec l’approche SMPD — rappelons que le nombre
d’éléments à traiter doit être une puissance de 2!

// //// / / // / / / / / / / / / / / / / / / / / / / / /
// FICHIER sommation_tableau . cl
// //// / / // / / / / / / / / / / / / / / / / / / / / /

__kernel void sommation_tableau ( __global const float * a ,


__global float * resultat ,
__local float * tampon ) {
int i = get_global_id (0);
int n = get_global_size (0);

tampon [ i ] = a [ i ];
barrier ( CLK_LOCAL_MEM_FENCE );

for ( int j = n / 2; j > 0; j /= 2 ) {


if ( i < j ) {
tampon [ i ] += tampon [ i + j ];
}

barrier ( CLK_LOCAL_MEM_FENCE );
}

if ( i == 0 ) {
* resultat = tampon [0];
}
}
Programmation parallèle OpenCL 550

La partie du programme qui appelle ce kernel est la suivante — le reste est


semblable à ce qui a été vu dans les exemples précédents.
// On alloue et initialise le tableau a traiter .
float a [ N ];
initialiser ( a , N );

// On cree les buffers pour transferer les donnees


// vers le GPU et ensuite recevoir le resultat .
cl_mem buffer_a =
clCreateBuffer ( contexte ,
CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR ,
sizeof ( float ) * N , a , NULL );
cl_mem buffer_resultat =
clCreateBuffer ( contexte ,
CL_MEM_WRITE_ONLY ,
sizeof ( float ) , NULL , NULL );

// On specifie les arguments du kernel .


clSetKernelArg ( kernel , 0 , sizeof ( cl_mem ) , & buffer_a );
clSetKernelArg ( kernel , 1 , sizeof ( cl_mem ) , & buffer_resultat );
clSetKernelArg ( kernel , 2 , sizeof ( float ) * N , NULL );

// On lance l ’ execution du kernel .


size_t nb_taches [] = { N };
clEnqueueNDRangeKernel ( queue , kernel , 1 , NULL , nb_taches ,
NULL , 0 , NULL , NULL );

// On lit le resultat calcule = > bloquant .


float total ;
clEnqueueReadBuffer ( queue , buffer_resultat , CL_TRUE , 0 ,
sizeof ( float ) , & total , 0 , NULL , NULL );
Soit l’extrait de code suivant qui vérifie ensuite que le résultat obtenu est correct :
// On verifie que le resultat est correct ( PRECISION pres ).
float attendu = somme_seq (a , N );
if ( fabsf ( attendu - total ) < PRECISION ) {
printf ( " OK pour % ld elements \ n " , N );
} else {
printf ( " *** Erreur : resultat incorrect : % f -- % f \ n " ,
total , attendu );
exit ( -1 );
}
Programmation parallèle OpenCL 551

Voici divers exemples d’exécution :


$ sommation_tableau 64
OK pour 64 elements

$ sommation_tableau 128
OK pour 128 elements

$ sommation_tableau 256
OK pour 256 elements

$ sommation_tableau 512
OK pour 512 elements

$ sommation_tableau 1024
*** Erreur: resultat incorrect: 24275.000000 -- 49317.000000

$ sommation_tableau 2048
*** Erreur: resultat incorrect: 24275.000000 -- 98634.000000

$ sommation_tableau 4096
*** Erreur: resultat incorrect: 24275.000000 -- 201257.000000

$ sommation_tableau 8192
*** Erreur: resultat incorrect: 24275.000000 -- 405363.000000
Programmation parallèle OpenCL 552

Caractéristiques de la machine (MacBook, Mac OS X 10.9.5) sur laquelle le pro-


gramme a été exécuté :
Platform 0
Name: Apple
Vendor: Apple

Found 2 device(s)

Device 0
Name: Intel(R) Core(TM) i5-4260U CPU @ 1.40GHz
Vendor: Intel
Compute Units: 4
Global Memory: 4294967296
Local Memory: 32768
Workgroup size: 1024

Device 1
Name: HD Graphics 5000
Vendor: Intel
Compute Units: 280
Global Memory: 1610612736
Local Memory: 65536
Workgroup size: 512

Faits :

• Si le nombre de work items est plus grand que le nombre de coeurs et que le
programmeur ne spécifie pas la décomposition en groupes, alors c’est OpenCL
qui gére la décomposition en groupes, dans un ordre arbitraire.
Par exemple :
2048 items avec un workgroup size de 512
⇒ 4 «passes»
⇒ le kernel est lancé sur les 512 coeurs 4 fois
⇒ pas le bon résultat pour cet exemple /

• Les exemples précédents (somme_tableaux, produit_matrices) fonctionnaient


correctement peu importe le nombre d’items à traiter.
Programmation parallèle OpenCL 553

Pourquoi la solution présentée plus haut pour somme_tableaux fonctionnait-elle


correctement peu importe le nombre d’éléments à traiter, alors que celle pour
sommation_tableau ne fonctionne pas correctement?
Exercice 11.2: Fonctionnement correct de somme_tableaux.

Source : http://www.nvidia.com/content/GTC/documents/1409_GTC09.pdf
Programmation parallèle OpenCL 554

Une solution correcte pour sommation_tableau nécessite donc d’utiliser une ap-
proche «semblable» à ce que fait la version Ruby suivante :

def sommation_tableau ( a , N )
resultats = Array . new ( nb_groupes )

PRuby . pcall ( 0... nb_groupes ,


lambda do | k |
b_inf = inf (k , N , nb_groupes )
b_sup = sup (k , N , nb_groupes )
resultats[k] = sommation_tranche(a, b_inf, b_sup)
end
)

total = 0
(0... nb_groupes ). each do | k |
total += resultats [ k ]
end
end
Programmation parallèle OpenCL 555

Pour otenir une solution correcte, il faut alors gérer et manipuler explicitement
les groupes et numéros de groupe et avoir en argument un pointeur vers un tableau
des resultats. Voici le kernel — notez get_local_id(0) et get_group_id(0) :
// // // / / / / / / / / / / / / / / / / / / / / / / / / / / / /
// FICHIER sommation_tableau - wg . cl
// // // / / / / / / / / / / / / / / / / / / / / / / / / / / / /

__kernel void sommation_tableau ( __global const float * a ,


__global float * resultats ,
__local float * tampon ) {
int i = get_local_id (0);
int n = get_local_size (0);

tampon [ i ] = a [ get_global_id (0)];


barrier ( CLK_LOCAL_MEM_FENCE );

for ( int j = n / 2; j > 0; j /= 2 ) {


if ( i < j ) {
tampon [ i ] += tampon [ i + j ];
}

barrier ( CLK_LOCAL_MEM_FENCE );
}

if ( i == 0) {
resultats [ get_group_id (0)] = tampon [0];
}
}
Programmation parallèle OpenCL 556
Programmation parallèle OpenCL 557

L’extrait du programme qui appelle le kernel est présenté ci-bas. Chaque groupe
calcule un résultat intermédiaire, et ces résultats sont ensuite être combinés.
Programmation parallèle OpenCL 558

Voici donc la version révisée du segment de code du programme principal qui appelle
le kernel :
float a [ N ];
initialiser ( a , N );

size_t taille_groupe = 512;


size_t nb_groupes = N / taille_groupe ;

cl_mem buffer_a =
clCreateBuffer ( contexte , CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR ,
sizeof ( float ) * N , a , NULL );
cl_mem buffer_resultats =
clCreateBuffer ( contexte , CL_MEM_WRITE_ONLY ,
sizeof ( float ) * nb_groupes , NULL , NULL );

clSetKernelArg ( kernel , 0 , sizeof ( cl_mem ) , & buffer_a );


clSetKernelArg ( kernel , 1 , sizeof ( cl_mem ) , & buffer_resultats );
clSetKernelArg ( kernel , 2 , sizeof ( float ) * taille_groupe , NULL );

size_t nb_taches [] = { N };
size_t tailles_groupes [] = { taille_groupe };
clEnqueueNDRangeKernel ( queue , kernel , 1 , NULL , nb_taches ,
tailles_groupes , 0 , NULL , NULL );

float resultats [ nb_groupes ];


clEnqueueReadBuffer ( queue , buffer_resultats , CL_TRUE , 0 ,
sizeof ( float ) * nb_groupes , resultats , 0 , NULL , NULL );

float total = 0.0;


for ( int i = 0; i < nb_groupes ; i ++ ) {
total += resultats [ i ];
}
Programmation parallèle OpenCL 559

• Voyez-vous le rapport avec la version TBB utilisant un parallel_reduce?

Exercice 11.3: Lien avec le parallel_reduce de TBB.

11.6 Conclusion
• L’utilisation de GPU est intéressante pour de nombreux problèmes avec du
parallélisme de données — et pas uniquement pour le traitement graphique!

• La programmation de GPU est. . . encore très (!?) difficile /


• Mais, cela pourrait devenir plus facile dans les années qui viennent :

– OpenACC: Permet l’utilisation de directives, comme en OpenMP


– Java et d’autres langages commencent à définir des bindings pour OpenCL
Chapitre 12

Programmation parallèle et
concurrente avec Java

Ce chapitre présente les notions de base de la programmation concurrente et parallèle


avec threads en Java, et ce entre autre à l’aide de divers exemples. Le chapitre
présente aussi divers éléments de la bibliothèque java.util.concurrent. Toutefois,
pour bien utiliser les versions plus récentes des threads et de cette bibliothèque, il
faut au préalable présenter les lambda-expressions, introduites en Java 7.0.

12.1 Lambda-expressions
Nous allons illustrer les lambda-expressions à l’aide de variables et d’interfaces
(explicites), pour mieux comprendre leur typage et montrer aussi que les lambda-
expressions sont des objets.
Une lambda-expression est un objet qui représente une méthode anonyme et
l’affectation à une variable est une façon de lui donner un nom. . . et de rendre
explicite la méthode associée.

12.1.1 Diverses interfaces utiles pour les lambda-expressions


et les threads
La bibliothèque java.util.concurrent définit un certain nombre d’interfaces de
de base, utiles pour la manipulation des threads, alors que java.util.function
définit diverses fonctions utiles pour des lambda-expressions.

Les interfaces Runnable, Callable<V> et Future<V>


Les extraits en anglais ci-bas sont extraits de la documentation Java d’Oracle.

560
Programmation Java 561

Runnable vs. Callable<V>


A task that returns a result and may throw an exception. Implementors define a
single method with no arguments called call.
The Callable interface is similar to Runnable, in that both are designed for
classes whose instances are potentially executed by another thread. A Runnable,
however, does not return a result and cannot throw a checked exception.
Un Runnable peut être appelé avec run() mais ne peut pas retourner de résultat (la
méthode run retourne le type void) /
interface Runnable {
void run ()
}
Par contre, un Callable, appelé avec call(), permet de retourner un résultat — de
type V (générique) ,
public interface Callable <V > {
V call ()
}

L’interface Future<V>
A Future represents the result of an asynchronous computation. Methods are pro-
vided to check if the computation is complete, to wait for its completion, and to
retrieve the result of the computation. The result can only be retrieved using method
get when the computation has completed, blocking if necessary until it is ready.
Cancellation is performed by the cancel method. Additional methods are provided
to determine if the task completed normally or was cancelled. Once a computation
has completed, the computation cannot be cancelled.
public interface Future <V > {
boolean cancel ( boolean mayInterruptIfRunning )

V get ()

V get ( long timeout , TimeUnit unit )

boolean isCancelled ()

boolean isDone ()
}
Note : Les interfaces Callable<V> et Future<V> sont définies dans java.util.concurrent,
donc il faut faire un import!
Programmation Java 562

Les interfaces de type FunctionalInterface


Une interface est fonctionnelle si elle n’exporte qu’une seule et unique méthode —
sauf peut-être aussi une ou des méthodes de la classe Object.
Functional interfaces provide target types for lambda expressions and method
references.
Source : https://docs.oracle.com/javase/8/docs/api/java/util/function/
package-summary.html
De nombreuses interfaces de ce genre (≈ 50) sont définies dans le package
java.util.function
BiConsumer<T,U> Represents an operation that accepts two input arguments and
returns no result.
BiFunction<T,U,R> Represents a function that accepts two arguments and produces a
result.
BinaryOperator<T> Represents an operation upon two operands of the same type, pro-
ducing a result of the same type as the operands.
BiPredicate<T,U> Represents a predicate (boolean-valued function) of two argu-
ments.
.. ..
. .
Function<T,R> Represents a function that accepts one argument and produces a
result.
.. ..
. .
ToIntBiFunction<T,U> Represents a function that accepts two arguments and produces
an int-valued result.
ToIntFunction<T> Represents a function that produces an int-valued result.
ToLongBiFunction<T,U> Represents a function that accepts two arguments and produces a
long-valued result.
ToLongFunction<T> Represents a function that produces a long-valued result.
UnaryOperator<T> Represents an operation on a single operand that produces a result
of the same type as its operand.

Un exemple : Function<T,R>

@FunctionalInterface
public interface Function <T ,R > {
// Applies this function to the given argument .
R apply ( T t )
}
Programmation Java 563

12.1.2 Des exemples de lambda-expressions


Des lambda-expressions de style Runnable (aucun argument ou résultat)

Runnable r0
= () -> { System . out . println ( " Dans r0 " ); };
r0 . run ();

int x = 0;
Runnable r1
= () -> System . out . println ( " x = " + x );
r1 . run ();

interface Fooable { void foo (); }


interface Barable { void bar (); }

Runnable r2 = () -> System . out . println ( " x = " + x );


Fooable f = () -> System . out . println ( " x = " + x );
Barable b = () -> System . out . println ( " x = " + x );

r2 . run ();
f . foo ();
b . bar ();
Par contre :
int x = 0;
Runnable r2
= () -> System . out . println ( " x = " + x );
r2 . run ();

x = 3;
Runnable r3
= () -> System . out . println ( " x = " + x );
r3 . run ();

-----------------------
Lambdas . java :57: error : local variables referenced from a
lambda expression must be final or effectively final
Runnable r2 = () -> System . out . println ( " x = " + x );
^
Lambdas . java :61: error : local variables referenced from a
lambda expression must be final or effectively final
Runnable r3 = () -> System . out . println ( " x = " + x );
Programmation Java 564

Des lambda-expressions Callable

// interface Callable <V > { V call (); }


import java . util . concurrent .*;

Callable < Integer > c1 = () -> { return 10; };


try { ... c1 . call () ... } ...

int xx = 0 , yy = 22;
Callable < Integer > c2 = () -> { return xx + yy ; };
try { ... c2 . call () ... } ...

Callable < Integer > c3 = () -> xx + yy ;


try { ... c3 . call () ... } ...

Des lambda-expressions qui satisfont une interface fonctionnelle du pack-


age java.util.function

// interface Function <T ,R > { R apply ( T t ); }


import java . util . function .*;

Function < Integer , Integer > f1


= ( w ) -> { return w +1; };

... f1 . apply (30) ...

int z = 22;
Function < Integer , Integer > f2
= ( w ) -> { return w + z ; };

... f2 . apply (30) ...

Function < Integer , Integer > f3


= w -> w + z ;
Programmation Java 565

... f3 . apply (30) ...


Des lambda-expressions qui satisfont une interface fonctionnelle définie
par le programmeur
Une interface avec une seule et unique méthode

interface Addable { int add ( int x ); }

int y = 0;

Addable a1 = ( int w ) -> { return w + y ; };


.. a1 . add (20) ...

Addable a2 = ( w ) -> { return w + y ; };


... a2 . add (20) ...

Addable a3 = w -> w + y ;
... a3 . add (20) ...
Par contre :
int y = 0;

Addable a4 = ( int y ) -> { return y + y ; };


... a4 . add (20) ...

=>
Lambdas . java :111: error : variable y is already defined in
method main ( String [])
Addable a4 = ( int y ) -> { return y + y ; };

Une interface avec plusieurs méthodes mais une seule non définie dans
Object

interface MonFooable {
void foo ();
int hashCode ();
String toString ();
}

MonFooable mf0 = () -> System . out . println ( " Dans mf0 " );
mf0 . foo ();
Programmation Java 566

Une interface avec plusieurs méthodes non définies dans Object

interface MesRunnables {
void run0 ();
void run1 ();
}

MesRunnables rs0 = () -> System . out . println ( " Dans rs0 " );

----
Lambdas . java :140: error : incompatible types :
MesRunnables is not a functional interface
MesRunnables rs0 = () -> System . out . println ( " Dans rs0 " );
^
multiple non - overriding abstract methods found
in interface MesRunnables
Programmation Java 567

12.2 Classe Thread vs. interface Runnable


La classe Thread est la classe de base, dont on peut hériter pour ensuite créer des
objets qui sont, via leur super-classe, des threads. Toutefois, il est plutôt recommandé
de réaliser un thread en mettant en oeuvre l’interface Runnable, et ce pour deux
raisons : i) parce que Java ne supporte pas l’héritage multiple, donc si on hérite de
Thread, alors on ne peut plus hériter d’une autre classe ; ii) parce qu’on ne peut
faire qu’un seul appel à start(), i.e., on ne peut pas avoir deux instances actives
du thread à moins de créer un nouvel objet tout à fait distinct.
L’interface Runnable est simplement définie comme suit :
interface Runnable {
void run ();
}

La «bonne nouvelle» en Java 8.0 : une lambda-expression est un Runnable ,


$ cat ExempleRunnable . java
...
Runnable r0
= () -> System . out . println ( " Bonjour ! " );
r0 . run ();
...
- -- -- - - - - - - - - - - - - -
$ java ExempleRunnable
Bonjour !

La classe Thread est, en partie, définie comme dans le Programme Java 12.1.
Programmation Java 568

Programme Java 12.1 La classe Thread de Java (spécification partielle).


class Thread implements Runnable {
Thread () {...}
Thread( Runnable target ) {...}
Thread (...) {...}

public static Thread currentThread () {...}

public static void sleep ( long milis ) {...}


public static void yield () {...}

public long getId () {...}


public long getName () {...}
public long getThreadGroup () {...}

public void interrupt () {...}


public boolean isAlive () {...}
public boolean isInterrupted () {...}

public void join () {...}


public void join (...) {...}

public void run () {...}


public void start () {...}
}
Programmation Java 569

Cycle de vie d’un thread «primitif» Les étapes de base d’utilisation d’un
thread sont les suivantes :
1. On crée un objet Thread.
2. On lance l’exécution de l’objet Thread avec la méthode start().
L’appel de cette méthode effectue alors, entre autre, un appel à la méthode
run() de l’objet.
3. On attend la fin de l’exécution de l’objet Thread en appelant la méthode
join().

Création et activation de threads Les deux principales façons pour créer une
instance de la classe Thread sont les suivantes :
• On définit une classe qui hérite de la classe Thread et qui définit une méthode
run :
class C extends Thread {
...
void run () { ... }
...
}

C c = new C ( ... );
c . start ();

Possible. . . mais on ne fait pas ça!


• On définit une classe qui met en oeuvre l’interface Runnable, et on crée un
thread en utilisant un constructeur approprié de la classe Thread, qui trans-
forme un Runnable en un Thread :
class C implements Runnable {
...
void run () { ... }
...
}
...
Thread t = new Thread ( new C () );
t . start ();

Méthode à utiliser de préférence : Via Runnable!


Mais en fait, de nos jours, avec les pools de threads, c’est assez rare qu’on crée
des threads de cette façon!
Programmation Java 570

12.3 Exemples simples comparant la création des


threads en MPD, PRuby et Java
Note : Certains de ces exemples qui suivent sont présentés en MPD, un langage qui
utilise une instruction de type cobegin/coend pour lancer des threads. Bien que
nous ne verrons pas ce langage dans le cours, les exemples devraient quand même
pouvoir être compris facilement — notamment parce qu’une version Ruby/PRuby est
aussi présentée.
Cette section présente une brève comparaison entre MPD, Ruby et Java quant
à la façon de créer des threads. Dans le cas de Java, plusieurs façons différentes de
créer ces threads sont parfois présentées, notamment pour illustrer l’évolution du
langage et les simplifications apportées par l’introduction des lambda-expressions.
Dans ces exemples, on suppose l’existence de la procédure et des fonctions sui-
vantes (exprimées ici en MPD) :
procedure pfoo ( int num_thread , int a1 )

procedure ffoo ( int num_thread , int a1 )


returns int

procedure bar ( int x )


returns int
La procédure pfoo et la fonction ffoo prennent en argument un numéro de
thread (pour l’identification du thread ) et un argument entier — on pourrait facile-
ment généraliser à 0, 1 ou plusieurs arguments, entiers ou non. Quant à la fonction
bar, elle sert simplement à représenter l’évaluation d’une expression passée en ar-
gument lors de l’appel de la procédure/fonction activée via un thread.
Trois formes d’activation de threads sont présentées :

1. Appel de procédure sans attente de terminaison — section 12.3.1


2. Appel de procédure avec attente de terminaison — section 12.3.2
3. Appel de fonction avec réception du résultat — section 12.3.3
Programmation Java 571

12.3.1 Appel de procédure sans attente de terminaison (exé-


cution en arrière-plan)
Version MPD

for [ k = 0 to nb_threads -1] {


fork pfoo ( k , bar ( k ) );
}

Version PRuby

(0... nb_threads ). each do | k |


PRuby . future { pfoo ( k , bar ( k ) ) }
end

Version Java avec classe interne anonyme

for ( int k = 0; k < nbThreads ; k ++ ) {


final int kf = k ;
new Thread ( new Runnable () {
public void run () {
pfoo ( kf , bar ( kf ) );
}
} ). start ();
}

Version Java 8.0 avec lambda-expression

for ( int k = 0; k < nbThreads ; k ++ ) {


final int kf = k ;
new Thread (
() -> pfoo ( kf , bar ( kf ) )
). start ();
}
Programmation Java 572

12.3.2 Appel de procédure avec attente de terminaison


Version MPD

co [ k = 0 to nb_threads -1]
pfoo ( k , bar ( k ) );
oc

Versions PRuby

# Avec pcall .
PRuby . pcall ( 0... nb_threads ,
lambda { | k | pfoo ( k , bar ( k ) ) }
)

# Avec future .
fs = (0... nb_threads ). map do | k |
PRuby . future { pfoo ( k , bar ( k ) ) }
end
fs . map (&: value )
Programmation Java 573

Version Java avec classe interne anonyme

Thread ts [] = new Thread [ nbThreads ];


for ( int k = 0; k < nbThreads ; k ++ ) {
final int kf = k ; // Var . non locale dans run .
ts [ k ] = new Thread ( new Runnable () {
public void run () {
pfoo ( kf , bar ( kf ) );
}
} );
ts [ k ]. start ();
}

for ( int k = 0; k < nbThreads ; k ++ ) {


try {
ts [ k ]. join ();
} catch ( InterruptedException ie ) {
...
}
}

Version Java 8.0 avec lambda-expression

Thread ts [] = new Thread [ nbThreads ];


for ( int k = 0; k < nbThreads ; k ++ ) {
final int kf = k ;
ts [ k ] = new Thread (
() -> pfoo ( kf , bar ( kf ) )
);
ts [ k ]. start ();
}

for ( int k = 0; k < nbThreads ; k ++ ) {


try {
ts [ k ]. join ();
} catch ( InterruptedException ie ) {
...
}
}
Programmation Java 574

Version Java avec classe auxiliaire

Thread ts [] = new Thread [ nbThreads ];


for ( int k = 0; k < nbThreads ; k ++ ) {
ts [ k ] = new Thread (
new ClassePourThread (k , bar ( k ))
);
ts [ k ]. start ();
}

for ( int k = 0; k < nbThreads ; k ++ ) {


try {
ts [ k ]. join ();
} catch ( InterruptedException ie ) {...}
}

// Classe auxiliaire pour representer le thread ,


// i . e . , l ’ appel de procedure et les arguments
// a transmettre ... pcq . run ne prend aucun argument :(
class ClassePourThread implements Runnable {
private int k , a1 ;

ClassePourThread ( int k , int a1 ) {


this . k = k ;
this . a1 = a1 ;
}

public void run () {


pfoo ( k , a1 );
}
}
Programmation Java 575

12.3.3 Appel de fonction et réception du résultat


Version MPD

int r [0: nb_threads -1]


co [ k = 0 to nb_threads -1]
r [ k ] = ffoo ( k , bar ( k ) );
oc

Version PRuby

fs = (0... nb_threads ). map do | k |


PRuby . future { ffoo ( k , bar ( k ) ) }
end
r = fs . map (&: value )
Programmation Java 576

Version Java 8.0 avec lambda-expression

ExecutorService pool
= Executors.newCachedThreadPool();
Future<Integer>[] fs
= new Future [ nbThreads ]; // unchecked cast
int r []
= new int [ nbThreads ];

for ( int k = 0; k < nbThreads ; k ++ ) {


final int kf = k ;
fs [ k ] = pool.submit(
() -> ffoo ( kf , bar ( kf ) )
);
}

for ( int k = 0; k < nbThreads ; k ++ ) {


try {
r [ k ] = fs [ k ].get();
} catch ( Exception e ) {
...
}
}

pool.shutdown();
Programmation Java 577

12.4 Fonction pour le calcul de π avec méthode Monte


Carlo
Le Programme Java 12.2 présente une fonction evaluerPi — et une fonction auxi-
liaire nbDansCercleSeq — pour estimer la valeur de π à l’aide d’une méthode de
Monte Carlo, donc semblable aux fonctions Ruby vues précédemment.
Programmation Java 578

Programme Java 12.2 Une fonction parallèle evaluerPi (et sa fonction auxili-
aire nbDansCercleSeq) pour approximer la valeur de π à l’aide de la méthode de
Monte Carlo. Il s’agit de la version Java d’une fonction Ruby vue précédemment :
Programme Ruby 5.10 (style impératif).
public static int nbDansCercleSeq ( int nbLancers ) {
Random rnd = new Random ();
int nb = 0;
for ( int k = 0; k < nbLancers ; k ++ ) {
double x = rnd . nextDouble ();
double y = rnd . nextDouble ();
if ( x * x + y * y <= 1.0 ) {
nb += 1;
}
}
return nb ;
}

//

@SuppressWarnings ( " unchecked " )


public static double evaluerPi ( final int nbLancers ,
int nbThreads ) {
ExecutorService pool
= Executors . newFixedThreadPool ( nbThreads );

// On lance les threads via des futures .


Future < Integer > lesNbs []
= new Future [ nbThreads ]; // unchecked cast
for ( int k = 0; k < nbThreads ; k ++ ) {
lesNbs [ k ] = pool . submit (
() -> nbDansCercleSeq ( nbLancers / nbThreads )
);
}

// On recoit les resultats .


int nbTotalDansCercle = 0;
for ( int k = 0; k < nbThreads ; k ++ ) {
try {
nbTotalDansCercle += lesNbs [ k ]. get ();
} catch ( Exception e ) {
}
}

pool . shutdown ();

return 4.0 * nbTotalDansCercle / nbLancers ;


}
Programmation Java 579

Figure 12.1: Graphe donnant le temps d’exécution du programme Pi.java pour dif-
férents nombres de threads. Les temps sont indiqués pour trois (3) séries différentes
d’exécution.

La Figure 12.1 présente les temps d’exécution pour trois séries distinctes d’exécu-
tion du programme Java 12.2, et ce en faisant varier le nombre de threads pour
effectuer un total de 10 000 000 lancers — donc 10 000 000 de lancers partagés
entre les divers threads. Pour plus de détails sur ces expérimentations et résultats,
voir la section 6.A.
Programmation Java 580

Autres graphes : Temps et accélération sur japet pour 100 000 000 de
lancers
Programmation Java 581

Autres graphes : Temps et accélération sur linux pour 100 000 000 de
lancers
Programmation Java 582

Un problème = Random est thread-safe. . . mais est partagé par les


différents threads /
Solution :
public static int nbDansCercleSeq ( int nbLancers ) {
ThreadLocalRandom rnd = ThreadLocalRandom . current ();
...
Programmation Java 583

Autres graphes : Temps et accélération sur linux pour 1 000 000 000 de
lancers
Programmation Java 584

12.5 Exemples des principaux patrons de program-


mation pour la somme de deux tableaux
Dans cette section, nous allons revoir divers patrons de programmation parallèle vus
précédemment en PRuby, mais cette fois exprimés en Java.
Le problème (simple!) qui sera traité consistera à effectuer la somme de deux
tableaux a et b, de même taille.

Note : Étant donné que nous utiliserons Java «de base» — donc les packages «stan-
dards» seulement, otamment java.util.concurrent — nous utiliserons toujours
du parallélisme fork–join.

12.5.1 Version avec parallélisme récursif et seuil de récursion,


avec threads Java et sans threads «inactifs»
Procédure auxiliaire :
private static void somme_seq_tranche ( int a [] , int b [] ,
int c [] ,
int bInf , int bSup ) {
for ( int i = bInf ; i <= bSup ; i ++ ) {
c [ i ] = a [ i ] + b [ i ];
}
}
Programmation Java 585

Programme Java 12.3 Parallélisme récursif avec création récursive d’un seul
thread, l’autre sous-problème étant traité par le thread parent.
//
// Parallelisme recursif avec seuil utilisant du parallelisme
// fork - join avec un seul Thread .
//
public static void somme_par_rec1_ij ( int a [] , int b [] ,
int c [] ,
int i , int j ,
int seuil ) {
if ( j - i + 1 <= seuil ) {
somme_seq_tranche ( a , b , c , i , j );
} else {
int mid = ( i + j ) / 2;
Thread gauche = new Thread (
() -> somme_par_rec1_ij ( a , b , c , i , mid , seuil )
);
gauche . start ();

somme_par_rec1_ij ( a , b , c , mid +1 , j , seuil );

try { gauche . join (); } catch ( Exception e ){};


}
}

public static int [] somme ( int a [] , int b [] , int seuil ) {


int n = a . length ;
int [] c = new int [ n ];

somme_par_rec1_ij ( a , b , c , 0 , n -1 , seuil );

return c ;
}
Programmation Java 586

12.5.2 Version avec parallélisme embarrassant — à granula-


rité fine

Programme Java 12.4 Parallélisme à granularité (très!) fine avec Threads — un


thread par position du tableau.
//
// Parallelisme style fork - join a granularite fine avec Threads .
//
public static int [] somme ( int a [] , int b [] ) {
int n = a . length ;
int c [] = new int [ n ];

Thread threads [] = new Thread [ n ];


for ( int i = 0; i < n ; i ++ ) {
int fi = i ;
threads [ i ] = new Thread (
() -> c [ fi ] = a [ fi ] + b [ fi ]
);
threads [ i ]. start ();
}

for ( int i = 0; i < n ; i ++ ) {


try { threads [ i ]. join (); } catch ( Exception e ){};
}

return c ;
}
Programmation Java 587

Programme Java 12.5 Parallélisme à granularité (très!) fine avec des Futures —
un thread par position du tableau.
//
// Parallelisme style fork - join a granularite fine avec Futures .
//
@SuppressWarnings ( " unchecked " )
public static int [] somme ( int a [] , int b [] ) {
int n = a . length ;
int c [] = new int [ n ];

ExecutorService pool = Executors . newCachedThreadPool ();


Future < Integer >[] futures = new Future [ n ];
for ( int i = 0; i < n ; i ++ ) {
int fi = i ;
futures [ i ] = pool . submit (
() -> a [ fi ] + b [ fi ]
);
}

for ( int i = 0; i < n ; i ++ ) {


try { c [ i ] = futures [ i ]. get (); } catch ( Exception e ){};
}

pool . shutdown ();


return c ;
}
Programmation Java 588

12.5.3 Version avec parallélisme à granularité grossière et at-


tribution statique des tâches aux threads

Programme Java 12.6 Parallélisme à granularité grossière avec répartition sta-


tique par blocs d’éléments adjacents.
//
// Parallelisme style fork - join a granularite grossiere avec
// Threads et repartition par blocs d ’ elements adjacents .
//
private static int inf ( int i , int n , int nbThreads )
{ return i * ( n / nbThreads ); }

private static int sup ( int i , int n , int nbThreads )


{ return ( i +1) * ( n / nbThreads ) - 1; }

public static int [] somme ( int a [] , int b [] , int nbThreads ) {


assert a . length % nbThreads == 0;
int n = a . length ;
int c [] = new int [ n ];

Thread [] threads = new Thread [ nbThreads ];


for ( int k = 0; k < nbThreads ; k ++ ) {
int bInf = inf (k , n , nbThreads );
int bSup = sup (k , n , nbThreads );
threads [ k ] = new Thread (
() -> somme_seq_tranche ( a , b , c , bInf , bSup )
);
threads [ k ]. start ();
}

for ( int k = 0; k < nbThreads ; k ++ ) {


try { threads [ k ]. join (); } catch ( Exception e ){};
}

return c ;
}
Programmation Java 589

Programme Java 12.7 Parallélisme à granularité grossière avec répartition sta-


tique et cyclique des éléments.
//
// Parallelisme style fork - join a granularite grossiere avec
// Threads et repartition cyclique des elements .
//
private static void somme_seq_cyclique ( int a [] , int b [] ,
int c [] ,
int bInf ,
int nbThreads ) {
for ( int i = bInf ; i < c . length ; i += nbThreads ) {
c [ i ] = a [ i ] + b [ i ];
}
}

public static int [] somme ( int a [] , int b [] ,


int nbThreads ) {
int n = a . length ;
int c [] = new int [ n ];

Thread [] threads = new Thread [ nbThreads ];


for ( int k = 0; k < nbThreads ; k ++ ) {
int fk = k ;
threads [ k ] = new Thread (
() -> somme_seq_cyclique ( a , b , c , fk , nbThreads )
);
threads [ k ]. start ();
}

for ( int k = 0; k < nbThreads ; k ++ ) {


try { threads [ k ]. join (); } catch ( Exception e ){};
}

return c ;
}
Programmation Java 590

12.5.4 Version avec parallélisme à granularité grossière et at-


tribution dynamique des tâches aux threads

Programme Java 12.8 Parallélisme à granularité grossière avec attribu-


tion dynamique par blocs d’éléments adjacents et pool de threads «standard»
(newFixedThreadPool).
//
// Parallelisme style Coordonnateur - Travailleurs -- donc avec une
// repartition dynamique des elements -- et utilisant au plus
// nbTravailleurs threads .
//
public static int [] somme ( int a [] , int b [] ,
int tailleTache ,
int nbTravailleurs ) {
assert a . length % tailleTache == 0;

int n = a . length ;
int [] c = new int [ n ];

ExecutorService pool
= Executors . newFixedThreadPool ( nbTravailleurs );

int nbTaches = n / tailleTache ;


Future <? >[] futures = new Future [ nbTaches ];
for ( int k = 0; k < nbTaches ; k ++ ) {
int fi = k * tailleTache ;
int fj = fi + tailleTache - 1;
futures [ k ] = pool . submit (
() -> somme_seq_tranche ( a , b , c , fi , fj )
);
}

// On s ’ assure que chaque tache a termine .


for ( int k = 0; k < nbTaches ; k ++ ) {
try { futures [ k ]. get (); } catch ( Exception e ){};
}
pool . shutdown ();

return c ;
}
Programmation Java 591

Description de newFixedThreadPool :

public static
ExecutorService newFixedThreadPool(int nThreads)
Creates a thread pool that reuses a fixed number of threads oper-
ating off a shared unbounded queue.

At any point, at most nThreads threads will be active processing tasks.


If additional tasks are submitted when all threads are active, they will
wait in the queue until a thread is available. [. . . ]

The threads in the pool will exist until it is explicitly shutdown.


Programmation Java 592

Programme Java 12.8 Parallélisme à granularité grossière avec attribu-


tion dynamique par blocs d’éléments adjacents et pool de threads «standard»
(newFixedThreadPool), avec une autre approche de terminaison.
//
// Parallelisme style Coordonnateur - Travailleurs -- donc avec une
// repartition dynamique des elements -- et utilisant au plus
// nbTravailleurs threads , mais avec une facon differente de
// terminer l ’ execution .
//
public static int [] somme ( int a [] , int b [] ,
int tailleTache ,
int nbTravailleurs ) {
int n = a . length ;
int [] c = new int [ n ];

ExecutorService pool
= Executors . newFixedThreadPool ( nbTravailleurs );

int nbTaches = n / tailleTache ;


Future <? >[] futures = new Future [ nbTaches ];
for ( int k = 0; k < nbTaches ; k ++ ) {
int fi = k * tailleTache ;
int fj = fi + tailleTache - 1;
futures [ k ] = pool . submit (
() -> somme_seq_tranche ( a , b , c , fi , fj )
);
}

pool . shutdown ();


try {
pool . awaitTermination ( Long . MAX_VALUE , TimeUnit . SECONDS );
} catch ( InterruptedException e ) {
}
assert pool . isTerminated ();
return c ;
}
Programmation Java 593

Programme Java 12.9 Parallélisme à granularité grossière avec attribu-


tion dynamique par blocs d’éléments adjacents et pool de threads hyper-légers
(ForkJoinPool).
//
// Parallelisme style Coordonnateur - Travailleurs -- donc avec une
// repartition dynamique des elements -- et utilisant au plus
// nbTravailleurs threads .
//
public static int [] somme ( int a [] , int b [] ,
int tailleTache ,
int nbTravailleurs ) {
assert a . length % tailleTache == 0;

int n = a . length ;
int [] c = new int [ n ];

ForkJoinPool pool = new ForkJoinPool ( nbTravailleurs );

int nbTaches = n / tailleTache ;


Future <? >[] futures = new Future [ nbTaches ];
for ( int k = 0; k < nbTaches ; k ++ ) {
int fi = k * tailleTache ;
int fj = fi + tailleTache - 1;
futures [ k ] = pool . submit (
() -> somme_seq_tranche ( a , b , c , fi , fj )
);
}

for ( int k = 0; k < nbTaches ; k ++ ) {


try { futures [ k ]. get (); } catch ( Exception e ){};
}

pool . shutdown ();


return c ;
}
Programmation Java 594

tailleTache
1000 100 10 1
newFixedThreadPool 0.14 0.18 0.77 8.52
ForkJoinPool 0.13 0.17 0.37 4.45

Tableau 12.1: Comparaison des temps d’exécution de deux versions de la méthode


somme : avec newFixedThreadPool vs. avec ForkJoinPool.

Description de ForkJoinTask et ForkJoinPool :

• ForkJoinTask :

A ForkJoinTask is a thread-like entity that is much lighter weight


than a normal thread.
Huge numbers of tasks and subtasks may be hosted by a small number
of actual threads in a ForkJoinPool, at the price of some usage
limitations.

https: // docs. oracle. com/ javase/ 8/ docs/ api/ java/ util/ concurrent/
ForkJoinPool. html

• ForkJoinPool :

An ExecutorService for running ForkJoinTasks. [. . . ]


A ForkJoinPool differs from other kinds of ExecutorService mainly
by virtue of employing work-stealing: all threads in the pool at-
tempt to find and execute tasks submitted to the pool and/or created
by other active tasks (eventually blocking waiting for work if none
exist). This enables efficient processing when most tasks spawn other
subtasks (as do most ForkJoinTasks), as well as when many small
tasks are submitted to the pool from external clients.

https: // docs. oracle. com/ javase/ 8/ docs/ api/ java/ util/ concurrent/
ForkJoinPool. html

Les temps d’exécution du tableau 12.1 ont été obtenus avec «time -p», pour
des tableaux comportant n =10 000 000 d’entiers, avec 8 threads (nbTravailleurs)
sur une machine avec 8 coeurs (Linux CentOS), et ce pour différentes valeurs de
tailleTache.
Question : Temps séquentiel? Temps par blocs d’éléments adjacents?
Programmation Java 595

12.6 Les objets comme moniteurs


12.6.1 Verrous et exclusion mutuelle
À tout objet Java est automatiquement associé un verrou et une variable de condi-
tion. Donc, tout objet Java est automatiquement un moniteur. Ainsi, soit la classe
Java suivante :
class Compteur {
private int val = 0;

public void inc () {


val += 1;
}

public int valeur () {


return val ;
}
}
Si deux threads distincts font un appel à inc(), alors il pourrait y avoir inter-
férence entre les deux threads. Pour éviter les situations de compétition, il faut donc
assurer que l’exécution de inc() puisse se faire de façon atomique et exclusive. La
façon la plus simple consiste à annoter la méthode comme étant synchronized :
class Compteur {
private int val = 0;

public synchronized void inc () {


val += 1;
}

public int valeur () {


return val ;
}
}

Est-il nécessaire ou approprié que la méthode valeur soit aussi synchronized?


Exercice 12.1: Méthode valeur : synchronized ou pas?

Le mot-clé synchronized peut donc être utilisée de deux façons possibles :


• Dans la signature d’une méthode, de façon à indiquer que la méthode doit
être exécutée de façon exclusive et atomique relativement aux autres méthodes
synchronized du même objet — donc comme une méthode de moniteur.
Programmation Java 596

• Comme une instruction explicite, dont la forme générale est la suivante :

synchronized( <objetQuelconque> ) {
<instructions à exécuter de façon exclusive>
}

En termes d’effets, la solution qui suit est équivalente à la précédente, bien que
la précédente soit plus explicite quant au comportement de la méthode (pas besoin
de regarder sa mise en oeuvre pour voir qu’il y a exclusion mutuelle) :
class Compteur {
private int val = 0;

public void inc () {


synchronized ( this ) {
val += 1;
}
}

public int valeur () {


return val ;
}
}
Il est important de comprendre qu’à tout objet est implicitement associé un ver-
rou utilisable par une instruction synchronized.
Programmation Java 597

Est-ce que le comportement des deux classes suivantes diffère? Si oui de quelle
façon?
class Compteur {
private int val1 = 0 , val2 = 0;

public synchronized void inc1 () {


val1 += 1;
}

public synchronized void inc2 () {


val2 += 1;
}
}

class Compteur {
private int val1 = 0 ,
val2 = 0;

private Object v1 = new Object () ,


v2 = new Object ();

public void inc1 () {


synchronized ( v1 ) { val1 += 1; }
}

public void inc2 () {


synchronized ( v2 ) { val2 += 1; }
}
}

Exercice 12.2: Différences entre deux classes Compteur?

12.6.2 Variables de condition


De la même façon qu’à tout objet est implicitement associé un verrou, à tout objet
est aussi implicitement associée une variable de condition. Trois méthodes de base
peuvent être utilisées sur de telles variables de condition :

• wait() : Mise en attente inconditionnelle.

• notify() : Envoi d’un signal à un des threads en attente.

• notifyAll() : Envoi d’un signal à tous les threads en attente.


Programmation Java 598

Programme Java 12.10 Moniteur Java pour une classe Semaphore.


public class Semaphore {
private int val ;

public Semaphore ( int valInitiale ) {


val = valInitiale ;
}

public synchronized void P () {


while ( val == 0 ) {
try { wait (); }
catch ( Exception e ) { ... }
}
val -= 1;
}

public synchronized void V () {


val += 1;
notify ();
}
}
Programmation Java 599

Le Programme Java 12.10 présente une classe Java appelée Semaphore réalisée
sous forme d’un moniteur. Quelques remarques concernant cet exemple :

• Les appels wait() et notify() manipulent la variable de condition implicite-


ment associée à l’objet courant, puisque ces appels sont équivalents aux appels
this.wait() et this.notify().
Si le moniteur requiert plusieurs variables de condition, on peut alors déclarer
et allouer plusieurs objets quelconque ou, encore mieux, utiliser des objets qui
satisfont l’interface Condition (voir section 12.A.3, p. 613).

• Un appel à la méthode wait() doit toujours être indiqué à l’intérieur d’une in-
struction try/catch, puisqu’il est possible que l’exception InterruptedException
soit signalée (exception qui indique que le thread a été interrompu et doit ter-
miner son exécution).

• Pour qu’un appel à la méthode wait(), notify() ou notifyAll() soit correct,


il doit être toujours être fait alors que le thread qui appelle la méthode est celui
qui a pris le contrôle du verrou.

1. Que fait un sémaphore?

2. Que font les opérations P() et V()?

Exercice 12.3: Que fait un sémaphore?

Le programme Java 12.11 illustre des exemples d’appels faits sans que le thread
appelant soit en possession du verrou.
Programmation Java 600

Programme Java 12.11 Extrait de programme Java qui illustre les erreurs sig-
nalées par des appels à wait() ou notify() sans que le thread appelant soit en
possession du verrou.
Object verrou = new Object ();

public void foo () {


verrou . notify ()
}

=>

Exception in thread " Thread -0 "


java . lang . I l l eg a l M o ni t o r S t at e E x c ep t i o n

-----

public void foo () {


try { wait (); } catch ( Exception e ) { ... }
}

=>

Exception in thread " Thread -0 "


java . lang . I l l eg a l M o n it o r S t at e E x c ep t i o n
Programmation Java 601

12.7 Interruption d’un thread


• Dans un programme avec plusieurs threads, l’avortement (cancellation) d’un
thread peut être nécessaire dans certains cas, par exemple :
– Lorsque l’usager avorte l’exécution d’une action (bouton <cancel>, touche
ˆC, etc.).
– Lorsque plusieurs threads sont lancés en parallèle pour trouver un résultat
et que leur exécution peut se terminer parce que le résultat a été trouvé
par l’un des threads, le travail des autres n’étant plus nécessaire.
– Lorsqu’un problème a été rencontré et que l’exécution doit être avortée.

• Chaque thread possède un flag indiquant s’il a été interrompu ou non.

• t.isInterrupted() permet d’examiner le statut du flag du thread t, mais


sans le resetter. Plus précisément, retourne true si le flag a été setté par
t.interrupt() et n’a pas été resetté depuis par l’utilisation de interrupted()
ou par l’exécution d’un wait(), sleep() ou join().

• Thread.interrupted() met le flag du thread courant à false et retourne


l’ancienne valeur (ne peut pas s’utiliser sur un autre thread ).

• t.interrupt() : met le flag du thread t à true, sauf si t est engagé dans un


wait(), sleep() ou join(). Dans ce cas, l’appel à interrupt() a plutôt pour
effet que l’action interrompue va lancer l’exception InterruptedException.
Ceci n’implique pas que le thread termine immédiatement son exécution. Entre
autres, il peut être nécessaire pour le thread de faire un peu de nettoyage avant
de vraiment se terminer. C’est au thread lui-même à déterminer à quelle
fréquence il va examiner son flag et déterminer ce qu’il doit faire.
Toutefois, une vérification du statut d’interruption est effectuée automatique-
ment lors de l’exécution des méthodes Object.wait(), Thread.join() et
Thread.sleep(). Si le flag a été setté, alors l’exception InterruptedException
est lancée (ce qui a alors pour effet de désactiver le flag).
Autre caractéristique importante : un thread en attente pour l’accès à un
verrou (appel d’une méthode synchronized) ne réagira pas à l’interruption
tant qu’il sera bloqué.
Programmation Java 602

12.8 Priorités des threads


• Le niveau de priorité d’un thread est une valeur entière comprise entre Thread.MIN_PRIO-
RITY (1) et Thread.MAX_PRIORITY (10)
• Par défaut, un thread a la même priorité que son parent (priorité de main =
NORM_PRIORITY = 5).
• Lecture et modification du niveau de priorité :
int getPriority ()
void setPriority ( int priority )

Le maximum permis dépend du groupe de threads duquel le thread fait partie.


• Convention pour les niveaux de priorités :
– 10 : Crise
– 7–9 : Processus interactif, gestion d’événements
– 4–6 : I/O-bound
– 2–3 : Calcul en arrière plan
– 1 : À exécuter seulement s’il n’y a rien d’autres à faire
• La politique exacte d’ordonnancement en fonction du niveau de priorité est
«implementation dependant».
De façon générale, la machine virtuelle va exécuter en premier les threads avec
un niveau de priorité plus élevé.
Toutefoisil faut aussi tenir compte de deux aspects importants :
– Inversion de priorité :
Supposons qu’un thread t1 tente d’acquérir un verrou qui est actuelle-
ment sous le contrôle d’un thread t2 de priorité plus faible. C’est donc
comme si le thread t1, qui est maintenant bloqué en attente que t2 libère
le verrou, s’exécutait au niveau de priorité de t2 ⇒ inversion de priorité.
Solution : héritage de priorité ⇒ si un thread t2 contrôle un verrou qui
est requis par un thread t1 de plus haut niveau de priorité, alors le niveau
de priorité de t2 est temporairement haussé au niveau de priorité de t1.
– Niveaux de priorité de Java vs. niveaux de priorité du système d’exploitation :
Règle générale, la priorité associée à un thread du SE n’est pas néces-
sairement identique à celle de la machine virtuelle Java. Pour éviter la
«famine» (un thread qui ne s’exécute jamais parce tous les autres threads
passent avant lui), la priorité d’un thread du SE est généralement définie
par une formule ayant l’allure suivante :
Programmation Java 603

<Vrai niveau de priorité >


= <Niveau de priorité Java>
+
<Nombre de "secondes" en attente de l’UCT >

Donc, ceci signifie que l’on ne peut pas se baser strictement sur le niveau
de priorité Java pour assurer un ordonnancement spécifique des threads.
Programmation Java 604

12.9 Quelques autres méthodes de la classe Thread


• Thread.currentThread() : retourne une référence au thread courant (celui
qui exécute l’appel de cette méthode).

• t.sleep( long milis ) : endort le thread pour la durée indiquée.

• Méthodes deprecated : suspend(), resume(), stop(), destroy().

Note : La classe Thread possède un (très!) grand nombre de constantes, construc-


teurs et méthodes :

• Constantes : MAX_PRIORITY, MIN_PRIORITY, NORM_PRIORITY ;

• Constructeurs : Huit (8) variantes différentes (possibilité d’indiquer le nom,


le groupe, un Runnable, etc.).

• Méthodes : ≈ 50 méthodes!
Programmation Java 605

12.10 Attribut volatile


• En Java, la lecture/écriture d’une variable simple (sauf pour les long et
double) s’effectue de façon atomique et indivisible. Une variable simple peut
donc être utilisée par deux threads pour s’échanger une valeur via lecture/écri-
ture sans danger d’interférence (donc sans danger qu’il n’y ait qu’une lecture
ou écriture partielle).
Mais : le modèle de mémoire de Java permet à un thread de conserver dans sa
mémoire locale (e.g., dans un registre de la machine) une copie de la variable
(cache mémoire). Donc, si un thread dépend d’une valeur écrite par un autre
thread, il pourrait ne pas voir l’effet de l’écriture.
• Par exemple, soit les deux méthodes suivantes définies dans une même classe
— l’effet de l’écriture suite à un appel à signalerFin() (par un autre thread )
pourrait ne pas être visible dans traiter() :
public class Exemple {
...
private boolean termine = false ;

public void traiter () {


while ( ! termine ) {
... faire quelque chose ...
}
}

public void signalerFin () {


termine = true ;
}
}

• Solution = ajouter l’attribut volatile :


private volatile boolean termine = false ;

Effet de volatile = à chaque lecture ou écriture d’une telle variable, un vrai


accès à la mémoire sera effectué, plutôt qu’un accès au cache ou aux registres
locaux.
L’attribut volatile peut donc être interprété comme une indication à la ma-
chine virtuelle qu’elle ne doit pas faire de copie dans l’espace local du thread.
• Autre solution, plus coûteuse = protéger l’accès par un verrou :
Programmation Java 606

private boolean termine = false ;


...
public void traiter () {
boolean termine ;
synchronized ( this ) {
termine = this . termine ;
};
while ( ! termine ) {
... faire quelque chose ...
synchronized ( this ) {
termine = this . termine ;
};
}
...
}
...
public synchronized void signalerFin () {
...
}

Lors de l’accès à un verrou, toutes les copies locales des variables du thread
sont mises à jour à partir de la mémoire. Lors de la libération du verrou,
toutes les variables modifiées sont copiées en mémoire.
Citation de http://www.javaperformancetuning.com/news/qotm030.shtml :

So where volatile only synchronizes the value of one variable be-


tween thread memory and “main” memory, synchronized synchro-
nizes the value of all variables between thread memory and “main”
memory [. . . ]. Clearly synchronized is likely to have more overhead
than volatile.
Programmation Java 607

Est-il nécessaire ou approprié que la méthode valeur ci-bas soit synchronized?


class Compteur {
private int val = 0;

public synchronized void inc () {


val += 1;
}

public int valeur () {


return val ;
}
}

Exercice 12.4: Méthode valeur : synchronized ou pas?

Autres informations sur les variables volatile

For the purposes of the Java programming language memory model, a


single write to a non-volatile long or double value is treated
as two separate writes: one to each 32-bit half. This can result in a
situation where a thread sees the first 32 bits of a 64-bit value from one
write, and the second 32 bits from another write.
Writes and reads of volatile long and double values are always atomic.
Writes to and reads of references are always atomic, regardless of whether
they are implemented as 32-bit or 64-bit values.
Source : https: // docs. oracle. com/ javase/ specs/ jls/ se7/ html/ jls-17. html

Le modèle mémoire de Java et les situations de compétition

A memory model describes, given a program and an execution trace of


that program, whether the execution trace is a legal execution of the pro-
gram. The Java programming language memory model works by examin-
ing each read in an execution trace and checking that the write observed
by that read is valid according to certain rules.
The memory model describes possible behaviors of a program. An im-
plementation is free to produce any code it likes, as long as all resulting
executions of a program produce a result that can be predicted by the
memory model.
Source : https: // docs. oracle. com/ javase/ specs/ jls/ se7/ html/ jls-17. html
Programmation Java 608

Qu’est-ce qui sera imprimé par le programme ci-bas?


class MemoryModel {
static int x = 0 ,
y = 0,
r1 ,
r2 ;

public static void main ( String [] args ) {


Thread t1 = new Thread ( () -> {
r1 = x ; // I1
y = 1; // I2
} );
Thread t2 = new Thread ( () -> {
r2 = y ; // I3
x = 2; // I4
} );
t1 . start (); t2 . start ();

try {
t1 . join (); t2 . join ();
} catch ( Exception e ){}

System . out . println ( " r1 = " + r1 +


"; " +
" r2 = " + r2 );
}
}

Exercice 12.5: Exemple spécial illustrant le modèle de mémoire de Java.


Programmation Java 609

12.11 Traitement des exceptions


Si durant l’exécution d’un thread une exception est signalée (throw) et que cette ex-
ception se propage au-delà de la méthode run, alors l’exécution du thread est consid-
érée comme s’étant terminée. Par défaut, une exception qui est ainsi propagée sans
être traitée dans le contexte du thread est traitée par la méthode uncaughtException()
associée au groupe de threads (classe ThreadGroup) duquel le thread fait partie :
void uncaughtException ( Thread t , Throwable e )
// Called by the Java Virtual Machine when
// a thread in this thread group stops
// because of an uncaught exception .
Par défaut, cette méthode imprime la trace de la pile d’activation. Cette méthode
peut aussi être redéfinie.
Programmation Java 610

12.A Quelques interfaces et classes disponibles dans


java.util.concurrent
Les descriptions et exemples qui suivent sont tirés du Web :
• http://docs.oracle.com/javase/7/docs/api/java/util/concurrent/package-summary.
html
La plupart sont disponibles dans le package java.util.concurrent.

12.A.1 Interface Lock


public interface Lock {
void lock ()
// Acquires the lock .

void lockInterruptibly ()
// Acquires the lock unless the current thread
// is interrupted .

Condition newCondition ()
// Returns a new Condition instance
// that is bound to this Lock instance .

boolean tryLock ()
// Acquires the lock only if it is free
// at the time of invocation .

boolean tryLock ( long time , TimeUnit unit )


// Acquires the lock if it is free within
// the given waiting time and the current
// thread has not been interrupted .

void unlock ()
// Releases the lock .
}
Pourquoi définir une telle interface ainsi que diverses classes qui mettent en oeuvre
cette interface? Réponse extraite de http://download.oracle.com/javase/1.5.
0/docs/api/java/util/concurrent/locks/Lock.html :
Programmation Java 611

Lock implementations provide more extensive locking operations than


can be obtained using synchronized methods and statements. They al-
low more flexible structuring, may have quite different properties, and
may support multiple associated Condition objects.
[. . . ]
The use of synchronized methods or statements provides access to the
implicit monitor lock associated with every object, but forces all lock ac-
quisition and release to occur in a block-structured way: when multiple
locks are acquired they must be released in the opposite order, and all
locks must be released in the same lexical scope in which they were ac-
quired.
While the scoping mechanism for synchronized methods and statements
makes it much easier to program with monitor locks, and helps avoid
many common programming errors involving locks, there are occasions
where you need to work with locks in a more flexible way. [. . . ]
With this increased flexibility comes additional responsibility. The ab-
sence of block-structured locking removes the automatic release of locks
that occurs with synchronized methods and statements. In most cases,
the following idiom should be used:

Lock l = ...;
l . lock ();
try {
// Se c t i o n c r i t i q u e ou on accede
// a la r e s s o u r c e p r o t e g e e par
// le verrou l .
...
} finally {
l . unlock ();
}

[. . . ] Lock implementations provide additional functionality over the use


of synchronized methods and statements by providing a non-blocking at-
tempt to acquire a lock (tryLock()), an attempt to acquire the lock that
can be interrupted (lockInterruptibly(), and an attempt to acquire the
lock that can timeout (tryLock(long, TimeUnit)).
A Lock class can also provide behavior and semantics that is quite differ-
ent from that of the implicit monitor lock, such as guaranteed ordering,
Programmation Java 612

non-reentrant usage, or deadlock detection. If an implementation pro-


vides such specialized semantics then the implementation must document
those semantics.
[. . . ]

12.A.2 Classe ReentrantLock


A reentrant mutual exclusion Lock with the same basic behavior and semantics as
the implicit monitor lock accessed using synchronized methods and statements, but
with extended capabilities.
A ReentrantLock is owned by the thread last successfully locking, but not yet
unlocking it. A thread invoking lock will return, successfully acquiring the lock,
when the lock is not owned by another thread. The method will return immediately
if the current thread already owns the lock. This can be checked using methods
isHeldByCurrentThread(), and getHoldCount().
public class ReentrantLock implements Lock {
ReentrantLock ()
ReentrantLock ( boolean fair )

int getHoldCount ()
protected Thread getOwner ()
protected Collection < Thread > getQueuedThreads ()
int getQueueLength ()
protected Collection < Thread > getWaitingThreads ( Condition condition )
int getWaitQueueLength ( Condition condition )
boolean hasQueuedThread ( Thread thread )
boolean hasQueuedThreads ()
boolean hasWaiters ( Condition condition )
boolean isFair ()
boolean isHeldByCurrentThread ()
boolean isLocked ()
void lock ()
void lockInterruptibly ()
Condition newCondition ()
String toString ()
boolean tryLock ()
boolean tryLock ( long timeout , TimeUnit unit )
void unlock ()
}
Programmation Java 613

Pourquoi la bibliothèque java.util.concurrent définit-elle une interface Lock


ainsi que des classes qui mettent en oeuvre cette interface — ReentrantLock
et ReadWriteReentrantLock — alors que des verrous sont déjà disponibles avec
synchronized et synchronized(this){...}?
Exercice 12.6: Interface Lock et classes associées.

Que se passe-t-il si un thread exécute une méthode synchronized puis tente


d’appeler une autre méthode elle aussi synchronized de la même classe?
Exercice 12.7: Appels multiples à synchronized.

12.A.3 Interface Condition


public interface Condition {
void await ()
boolean await ( long time , TimeUnit unit )
long awaitNanos ( long nanosTimeout )
void awaitUninterruptibly ()
boolean awaitUntil ( Date deadline )
void signal ()
void signalAll ()
}

public interface Lock {


...
Condition newCondition()
// Returns a new Condition instance
// that is bound to this Lock instance .
...
}
Donc, lorsqu’on fait cond.await(), c’est le verrou parent qui est libéré, puis réacquis
après réception du signal().

12.A.4 Classe Semaphore


public class Semaphore {
Semaphore ( int permits )
Semaphore ( int permits , boolean fair )

void acquire ()
void acquire ( int permits )
Programmation Java 614

void acquireUninterruptibly ()
void acquireUninterruptibly ( int permits )

int availablePermits ()
int drainPermits ()
protected Collection < Thread > getQueuedThreads ()
int getQueueLength ()
boolean hasQueuedThreads ()
boolean isFair ()
protected void reducePermits ( int reduction )

void release ()
void release ( int permits )

boolean tryAcquire ()
boolean tryAcquire ( int permits )
boolean tryAcquire ( int permits , long timeout , TimeUnit unit )
boolean tryAcquire ( long timeout , TimeUnit unit )
}

Est-ce que «acquire(2);» a le même effet que «acquire(); acquire()»?


Exercice 12.8: Différences entre appels à acquire?

12.A.5 Classe CyclicBarrier


public class CyclicBarrier {
CyclicBarrier ( int parties )
// Creates a new CyclicBarrier that will trip
// when the given number of parties ( threads )
// are waiting upon it , and does not perform
// a predefined action upon each barrier .
...

int await ()
// Waits until all parties have invoked await on this barrier .

int await ( long timeout , TimeUnit unit )


// Waits until all parties have invoked await on this barrier .

int getNumberWaiting ()
// Returns the number of parties currently waiting at the barrier .
Programmation Java 615

int getParties ()
// Returns the number of parties required to trip this barrier .

...

void reset ()
// Resets the barrier to its initial state .
}

12.A.6 Classe CountDownLatch


A synchronization aid that allows one or more threads to wait until a set of opera-
tions being performed in other threads completes.
A CountDownLatch is initialized with a given count. The await methods block
until the current count reaches zero due to invocations of the countDown() method,
after which all waiting threads are released and any subsequent invocations of await
return immediately. This is a one-shot phenomenon — the count cannot be reset.
If you need a version that resets the count, consider using a CyclicBarrier.

public class CountDownLatch {


CountDownLatch ( int count )
// Constructs a CountDownLatch initialized
// with the given count .

void await ()
// Causes the current thread to wait until
// the latch has counted down to zero ,
// unless the thread is interrupted .

boolean await ( long timeout , TimeUnit unit )


// Causes the current thread to wait until the latch has counted down
// to zero , unless the thread is interrupted , or the specified waiting
// time elapses .

void countDown ()
// Decrements the count of the latch , releasing all waiting
// threads if the count reaches zero .

long getCount ()
// Returns the current count .
...
}
Programmation Java 616

12.A.7 Classe Exchanger<V>


A synchronization point at which threads can pair and swap elements within pairs.
Each thread presents some object on entry to the exchange method, matches with a
partner thread, and receives its partner’s object on return.

public class Exchanger <V > {


Exchanger ()
// Creates a new Exchanger .

V exchange ( V x )
// Waits for another thread to arrive at this
// exchange point ( unless the current thread
// is interrupted ) , and then transfers the given
// object to it , receiving its object in return .

V exchange ( V x , long timeout , TimeUnit unit )


}

12.A.8 Classes pour des objets atomiques


Diverses classes — AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference
— permettent de manipuler des objets (par opposition à des valeurs de types prim-
itifs tels int, boolean) de façon atomique. Dans le cas des classes AtomicInteger
et AtomicReference, les opérations sont les suivantes :
public class AtomicInteger {
AtomicInteger ()
AtomicInteger ( int initialValue )

int addAndGet ( int delta )


// Atomically adds the given value to the current value .
// Returns : the updated value

boolean compareAndSet ( int expect , int update )


int decrementAndGet ()
int get ()

int getAndAdd ( int delta )


// Atomically adds the given value to the current value .
// Returns : the previous value

int getAndDecrement ()
Programmation Java 617

int getAndIncrement ()
int getAndSet ( int newValue )
int incrementAndGet ()
int intValue ()
long longValue ()
void set ( int newValue )
String toString ()
...
}
Exemples :
• Supposons un seul et unique thread :
AtomicInteger ai = new AtomicInteger ( 17 );

assert ai . get () == 17;


assert ! ai . compareAndSet ( 19 , 23 );
assert ai . get () == 17;

assert ai . compareAndSet ( 17 , 23 );
assert ai . get () == 23;

• Supposons deux threads :


AtomicInteger ai = new AtomicInteger ( 17 );

Thread t0 = new Thread ( () -> {


int x = ai . get (); ai . compareAndSet ( x , x +1 );
} );

Thread t1 = new Thread ( () -> {


int y = ai . get (); ai . compareAndSet ( y , y +1 );
} );

t0 . start (); t1 . start ();

try { t0 . join (); t1 . join (); } catch ( Exception e ){ ... }

ai . get () == ? ?;
Quelle est la valeur — ou les valeurs — qui peut être retournée par ai.get()?
Exercice 12.9: Valeur possible pour un AtomicInteger
Programmation Java 618

Supposons que l’on ait la classe AtomicInteger avec compareAndSet() mais


sans increment() :
public final boolean compareAndSet ( int expect ,
int update )

Voici alors une mise en oeuvre de increment() :


public int increment () {
int valeurAvant ,
valeurApres ;

do {
valeurAvant = valeur . get ();
valeurApres = valeurAvant + 1;
} while ( ! valeur . compareAndSet ( valeurAvant ,
valeurApres ) );

return valeurApres ;
}

Dans la méthode increment, quand l’appel à compareAndSet peut-il retourner


false?
Exercice 12.10: Méthode compareAndSet.

public class AtomicReference <V > {


AtomicReference ()

AtomicReference ( V initialValue )

boolean compareAndSet ( V expect , V update )


// Atomically sets the value to the given updated
// value if the current value == the expected value .

V get ()

V getAndSet ( V newValue )
// Atomically sets to the given value
// and returns the old value .

void set ( V newValue )


Programmation Java 619

String toString ()
}
Pour manipuler des Doubles de façon atomique, on doit définir soi même une
classe appropriée :
class AtomicDouble {
private AtomicReference < Double > value ;

public AtomicDouble ( double initVal ) {


value = new AtomicReference < Double >(
new Double ( initVal ) );
}

public double get () {


return value . get (). doubleValue ();
}

public boolean compareAndSet ( double expect ,


double update ) {
Double origVal , newVal ;

newVal = new Double ( update );


while ( true ) {
origVal = value . get ();

if ( Double . compare ( origVal . doubleValue () , expect ) == 0) {


if ( value . compareAndSet ( origVal , newVal ) ) {
return true ;
}
} else {
return false ;
}
}
}
}

12.A.9 Interfaces Callable<V> et Future<V>


Callable<V>
A task that returns a result and may throw an exception. Implementors define a
single method with no arguments called call.
The Callable interface is similar to Runnable, in that both are designed for
classes whose instances are potentially executed by another thread. A Runnable,
however, does not return a result and cannot throw a checked exception.
Un Runnable ne peut pas retourner de résultat :(
Programmation Java 620

interface Runnable {
void run ();
}
Un Callable permet de retourner un résultat :)
public interface Callable <V > {
V call ()
// Computes a result , or throws an exception
// if unable to do so .
}

Future<V>
A Future represents the result of an asynchronous computation. Methods are pro-
vided to check if the computation is complete, to wait for its completion, and to
retrieve the result of the computation. The result can only be retrieved using method
get when the computation has completed, blocking if necessary until it is ready.
Cancellation is performed by the cancel method. Additional methods are provided
to determine if the task completed normally or was cancelled. Once a computation
has completed, the computation cannot be cancelled.
public interface Future <V > {
boolean cancel ( boolean mayInterruptIfRunning )
// Attempts to cancel execution of this task .

V get ()
// Waits if necessary for the computation
// to complete , and then retrieves its result .

V get ( long timeout , TimeUnit unit )


// Waits if necessary for at most the given time
// for the computation to complete , and
// then retrieves its result , if available .

boolean isCancelled ()
// Returns true if this task was cancelled
// before it completed normally .

boolean isDone ()
// Returns true if this task completed .
}
Exemple d’utilisation :
Programmation Java 621

public interface ArchiveSearcher {


String search ( String target );
}

class App {
ExecutorService executor = ...
ArchiveSearcher searcher = ...

void showSearch ( final String target )


throws InterruptedException {
Future < String > future = executor . submit (
new Callable < String >() {
public String call () {
return searcher . search ( target );
}
});
displayOtherThings (); // do other things while searching

try {
displayText ( future . get ()); // use future
} catch ( ExecutionException ee ) {
cleanup (); return ;
}
}
}

12.A.10 Pool de threads


L’utilisation d’un pool de threads permet de créer un certain nombre de threads qui
vont rester «vivants» aussi longtemps que le pool existera, si nécessaire en restant
«inactifs» en attente de travail à faire. Lorsqu’une tâche sera donnée à exécuter
au pool (via un Runnable ou un Callable, décrit dans la section précédente), un
des threads du pool l’exécutera, puis retournera en attente de nouvelles tâches.
On utilise un pool de threads lorsqu’on veut avoir un nombre maximum de threads
actifs à un instant donné, tout en ayant une certaine flexibilité quant à leur affec-
tation (i.e., les threads vont effectuer des tâches diverses tout au long de leur durée
de vie).
Pour des tâches de fine granularité, l’utilisation d’un pool de threads évite de
créer de façon répétitive de nouveaux threads, qui ne s’exécuteraient que pour une
courte période, puis se termineraient — donc avec un overhead élevé de création
puis destruction du thread par rapport au travail effectué par le thread.
Programmation Java 622

Interface Executor
L’interface de base pour les pool de threads est la suivante :
public interface Executor {
public void execute ( Runnable task );
}

Interface ExecutorService
Extraits de l’interface :
public interface ExecutorService {
boolean awaitTermination ( long timeout , TimeUnit unit )
// Blocks until all tasks have completed execution after
// a shutdown request , or the timeout occurs , or
// the current thread is interrupted , whichever happens first .

<T > List < Future <T > >


invokeAll ( Collection < Callable <T > > tasks )
// Executes the given tasks , returning a list of Futures
// holding their status and results when all complete .

<T > T
invokeAny ( Collection < Callable <T > > tasks )
// Executes the given tasks , returning the result of one that has
// completed successfully ( i . e . , without throwing an exception ) , if any do .

boolean isShutdown ()
// Returns true if this executor has been shut down .

boolean isTerminated ()
// Returns true if all tasks have completed following
// shut down .

void shutdown ()
// Initiates an orderly shutdown in which previously
// submitted tasks are executed ,
// but no new tasks will be accepted .

List < Runnable > shutdownNow ()


// Attempts to stop all actively executing tasks ,
// halts the processing of waiting tasks , and
// returns a list of the tasks that were awaiting execution .
Programmation Java 623

<T > Future <T > submit ( Callable <T > task )}
// Submits a value - returning task for execution
// and returns a Future representing the pending
// results of the task .

Future <? > submit ( Runnable task )


// Submits a Runnable task for execution and returns a
// Future representing that task .

<T > Future <T > submit ( Runnable task , T result )


// Submits a Runnable task for execution and returns
// a Future representing that task that will upon
// completion return the given result

Classe de base pour un ExecutorService : ThreadPoolExecutor


Extraits de la classe — comporte approx. 40 méthodes :
Class ThreadPoolExecutor implements Executor , ExecutorService {

ThreadPoolExecutor ( int corePoolSize ,


int maximumPoolSize ,
long keepAliveTime ,
TimeUnit unit ,
BlockingQueue < Runnable > workQueue )
// Creates a new ThreadPoolExecutor with the given
// initial parameters and default thread factory
// and handler .
...

void execute ( Runnable command )


// Executes the given task sometime
// in the future .
...

int getActiveCount ()
// Returns the approximate number of threads that are
// actively executing tasks .

long getCompletedTaskCount ()
// Returns the approximate total number of tasks that have
// completed execution .
...
Programmation Java 624

void purge ()
// Tries to remove from the work queue all Future tasks
// that have been cancelled .

boolean remove ( Runnable task )


// Removes this task from the executor ’s internal queue
// if it is present , thus causing it not to be run
// if it has not already started .

...

void shutdown ()
// Initiates an orderly shutdown in which previously submitted
// tasks are executed , but no new tasks will be accepted .

List < Runnable > shutdownNow ()


// Attempts to stop all actively executing tasks , halts the
// processing of waiting tasks , and returns a list of the
// tasks that were awaiting execution .

}
Remarque : ThreadPoolExecutor est une sous-classe de AbstractExecutorService,
laquelle classe définit, entre autres, la méthode suivante :
<T > Future <T > submit ( Callable <T > task )
Effets des paramètres (du constructeur) sur le comportement des threads et du
pool :
• A ThreadPoolExecutor will automatically adjust the pool size according to the bounds
set by corePoolSize and maximumPoolSize.
When a new task is submitted in method execute, and fewer than corePoolSize
threads are running, a new thread is created to handle the request, even if other
worker threads are idle.
If there are more than corePoolSize but less than maximumPoolSize threads run-
ning, a new thread will be created only if the queue is full.
By setting corePoolSize and maximumPoolSize the same, you create a fixed-size
thread pool. By setting maximumPoolSize to an essentially unbounded value such
as Integer.MAX_VALUE, you allow the pool to accommodate an arbitrary number of
concurrent tasks.

• If the pool currently has more than corePoolSize threads, excess threads will be
terminated if they have been idle for more than the keepAliveTime. This provides
Programmation Java 625

a means of reducing resource consumption when the pool is not being actively used.
If the pool becomes more active later, new threads will be constructed.

• Any BlockingQueue may be used to transfer and hold submitted tasks. The use of
this queue interacts with pool sizing:
– If fewer than corePoolSize threads are running, the Executor always prefers
adding a new thread rather than queuing.
– If corePoolSize or more threads are running, the Executor always prefers
queuing a request rather than adding a new thread.
– If a request cannot be queued, a new thread is created unless this would exceed
maximumPoolSize, in which case, the task will be rejected.

Méthodes de fabrication pour créer un ExecutorService


Les trois principales (parmi une vingtaine d’autres) :
class Executors {
// Methods that create and return an ExecutorService
// set up with commonly useful configuration settings .
...
static ExecutorService
newCachedThreadPool ()
// Creates a thread pool that creates new threads
// as needed , but will reuse previously constructed
// threads when they are available .

static ExecutorService
newFixedThreadPool ( int nThreads )
// Creates a thread pool that reuses a fixed set
// of threads operating off a shared unbounded queue .

static ExecutorService
newSin gleThreadE xecutor ()
// Creates an Executor that uses a single worker
// thread operating off an unbounded queue .
// Tasks are guaranteed to execute sequentially ,
// and no more than one task will be active
// at any given time .
}
Quelques informations supplémentaires :
• newCachedThreadPool : Creates a thread pool that creates new threads as needed,
but will reuse previously constructed threads when they are available. These pools
Programmation Java 626

will typically improve the performance of programs that execute many short-lived
asynchronous tasks. Calls to execute will reuse previously constructed threads if
available. If no existing thread is available, a new thread will be created and added
to the pool. Threads that have not been used for sixty seconds are terminated and
removed from the cache. Thus, a pool that remains idle for long enough will not
consume any resources. Note that pools with similar properties but different details
(for example, timeout parameters) may be created.
• newFixedThreadPool : Creates a thread pool that reuses a fixed set of threads op-
erating off a shared unbounded queue. If any thread terminates due to a failure
during execution prior to shutdown, a new one will take its place if needed to execute
subsequent tasks.

12.A.11 Classe ThreadLocal<T>


This class provides thread-local variables. These variables differ from their normal
counterparts in that each thread that accesses one (via its get or set method) has
its own, independently initialized copy of the variable. ThreadLocal instances are
typically private static fields in classes that wish to associate state with a thread
(e.g., a user ID or Transaction ID).
public class ThreadLocal <T > {
ThreadLocal ()

Object get ()
// Returns the value in the current thread ’s copy
// of this thread - local variable .

protected Object initialValue ()


// Returns the current thread ’s initial value
// for this thread - local variable .

void set ( Object value )


// Sets the current thread ’s copy of this thread - local
// variable to the specified value .
}
Exemple d’utilisation : For example, in the class below, the private static ThreadLocal
instance (serialNum) maintains a “serial number” for each thread that invokes the
class’s static SerialNum.get() method, which returns the current thread’s serial
number. (A thread’s serial number is assigned the first time it invokes SerialNum.get(),
and remains unchanged on subsequent calls.)
Each thread holds an implicit reference to its copy of a thread-local variable as
long as the thread is alive and the ThreadLocal instance is accessible; after a thread
Programmation Java 627

goes away, all of its copies of thread-local instances are subject to garbage collection
(unless other references to these copies exist).
public class SerialNum {
// The next serial number to be assigned
private static int nextSerialNum = 0;

private static ThreadLocal serialNum


= new ThreadLocal () {
protected synchronized Object
initialValue () {
return new Integer ( nextSerialNum ++);
}
};

public static int get () {


return (( Integer ) ( serialNum . get ())). intValue ();
}
}

12.A.12 Les collections concurrentes


Les principales collections concurrentes disponibles en Java 8 — package java.util.concurrent :

• ArrayBlockingQueue<E>

• ConcurrentHashMap<K,V>

• ConcurrentLinkedDeque<E>

• ConcurrentLinkedQueue<E>

• ConcurrentSkipListMap<K,V>

• ConcurrentSkipListSet<K,V>

ConcurrentHashMap<K,V> :

A hash table supporting full concurrency of retrievals and high expected


concurrency for updates. This class obeys the same functional speci-
fication as Hashtable and includes versions of methods corresponding
to each method of Hashtable.However, even though all operations are
thread-safe, retrieval operations do not entail locking, and there is not
any support for locking the entire table in a way that prevents all access.
Programmation Java 628

Retrieval operations (including get) generally do not block, so may over-


lap with update operations (including put and remove).
Source : https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/
ConcurrentHashMap.html

• ConcurrentHashMap :
– It is thread safe without synchronizing the whole map.
– Reads can happen very fast while write is done with a lock.
– There is no locking at the object level.
– The locking is at a much finer granularity at a hashmap bucket level.

• SynchronizedHashMap (ancienne version!)


– Synchronization at Object level.
– Every read/write operation needs to acquire lock.
– Locking the entire collection is a performance overhead.
– This essentially gives access to only one thread to the entire map & blocks
all the other threads.

Source : http: // crunchify. com/ hashmap-vs-concurrenthashmap-vs-synchronizedmap-how-a-hashm


Programmation Java 629

12.B Comparaison des performances entre synchronized,


ReentrantLock et AtomicInteger
Dans cette section, nous allons comparer les performances relatives entre synchronized,
ReentrantLock et AtomicInteger.
Programmation Java 630

Programme Java 12.12 Une classe VarInteger.


// Modelise une variable contenant un entier .
class VarInteger {
private int val = 0;
private final ReentrantLock lock ; // Pour add2 .
private AtomicInteger atomicVal ; // Pour add3 .

VarInteger () {
lock = new ReentrantLock ();
atomicVal = new AtomicInteger ( 0 );
}

// Methode sans exclusion mutelle .


void add0 ( int x ) { val += x ; }

// Methode avec verrou " primitif ".


synchronized void add1 ( int x ) { val += x ; }

// Methode avec ReentrantLock .


void add2 ( int x ) { lock . lock (); val += x ; lock . unlock (); }

// Methode avec AtomicInteger .


void add3 ( int x ) { val = atomicVal . addAndGet ( x ); }

// Methode pour dispatcher .


void add ( int numMethode , int x ) {
switch ( numMethode ) {
case 0: add0 ( x ); break ;
case 1: add1 ( x ); break ;
case 2: add2 ( x ); break ;
case 3: add3 ( x ); break ;
}
}

public int value () { return val ; }


}
Programmation Java 631

Programme Java 12.13 Un programme de benchmark utilisant VarInteger.


// On alloue un tableau .
int a [] = new int [ nbElements ];
for ( int i = 0; i < nbElements ; i ++ ) { a [ i ] = 1; }

// On va executer pour divers nombres de threads .


int [] lesNbsThreads = {1 , 2 , 4 , 8 , 16 , 32 , 64};

System . out . format ( " #% s %7 s %7 s %7 s %7 s \ n " ,


" nb . thr . " , " add0 " , " add1 " , " add2 " , " add3 " );
for ( int nbThreads : lesNbsThreads ) {
System . out . format ( " %8 d " , nbThreads );
for ( int method = 0; method <= 3; method ++ ) {
int numMethode = method ;

// On lance la minuterie .
long tempsDebut = System . currentTimeMillis ();

// On lance les threads , selon la methode demandee .


VarInteger total = new VarInteger ();
Thread [] threads = new Thread [ nbThreads ];
for ( int i = 0; i < nbThreads ; i ++ ) {
threads [ i ] = new Thread ( () -> {
for( int x: a ) { total.add( numMethode, x ); };
} );
threads [ i ]. start ();
}
for ( Thread t : threads ) {
try { t . join (); } catch ( InterruptedException e ) {}
}

// On arrete la minuterie .
long tempsFin = System . currentTimeMillis ();
System . out . format ( " %7 d " , ( tempsFin - tempsDebut ) );

}
System . out . format ( " \ n " );
}
Programmation Java 632
Programmation Java 633
Programmation Java 634
Programmation Java 635
Programmation Java 636
Programmation Java 637
Programmation Java 638

12.C Exemple avec streams (paquetage java.util.stream)


Le Programme Java 12.14 présente un exemple d’utilisation des streams de Java 8.0.
Il s’agit d’une fonction pour trier les mots d’un fichier, de façon unique, donc sem-
blable aux fonctions présentées dans les Programmes Ruby 5.25 et 5.28 — semblable
mais pas identique, notamment puisque les mots triés sont simplement émis sur
stdout plutôt que dans un fichier texte spécifique.

• L’appel à la méthode parallel() aurait pu être omis et le même résultat


aurait été produit, mais sans exécution parallèle.

• Le tri avec sorted se fait, par défaut, avec la méthode String::compareTo.

• Le stream étant parallèle, son contenu doit être examiné avec forEachOrdered
pour que l’impression se fasse en respectant l’ordre des éléments triés.

Pour plus de détails sur les streams Java, voir la documentation en ligne d’Oracle :
https://docs.oracle.com/javase/8/docs/api/java/util/stream/package-summary.
html
Effet de flat_map (version Ruby) :
>> [[10 , 20 , 30] , [] , [88 , 99]].
flat_map { | x | x }
= > [10 , 20 , 30 , 88 , 99]

>> [ " abc def " , " xy " , " 1 2 3 " ].


flat_map { | x | x . split ( " " ) }
= > [ " abc " , " def " , " xy " , " 1 " , " 2 " , " 3 " ]
Programmation Java 639

Programme Java 12.14 Fonction pour trier les mots d’un fichier, en s’assurant
que chaque mot apparaît au plus une fois — version avec streamsde Java 8.0.
private static Stream < String >
lignes ( String nomFichier ) {
try {
return
new BufferedReader (
new FileReader ( nomFichier )
). lines ();
} catch ( Exception e ) {
...
}
}

private static Stream < String >


genererMots ( String ligne ) {
return Stream . of ( ligne . split ( " " ) );
}

private static boolean


motValide ( String mot ) {
Pattern pattern = Pattern . compile ( " \\ w + " );
Matcher matcher = pattern . matcher ( mot );
return matcher . find ();
}

public static void


trierMotsUniques ( String fichEntree ) {
lignes ( fichEntree )
. parallel ()
. flatMap ( l -> genererMots ( l ) )
. filter ( m -> motValide ( m ) )
. sorted ()
. distinct ()
. forEachOrdered ( System . out :: println );
}
Programmation Java 640

12.D Allocation dynamique de tableaux génériques


L’allocation directe de tableaux génériques n’est pas «naturelle» en Java.
Soit la classe générique suivante :
class Pair <T ,U > {
T first ;
U second ;

Pair ( T f , U s ) {
first = f ;
second = s ;
}

T first () { return first ; }


U second () { return second ; }
}
On peut utiliser le «diamond » — <> — pour éviter de spécifier de façon explicite
certains types, qui peuvent être inférés par le compilateur Java (inférence de types) :
// Forme explicite .
Pair < String , String > p
= new Pair < String , String >( " 10 " , " abc " );

// Forme implicite ( inference de type ).


Pair < String , String > p
= new Pair < >( " 10 " , " abc " );
Par contre, on ne peut pas utiliser le diamond pour allouer dynamiquement un
tableau générique.
Les fragments de code suivants génèrent des erreurs de compilation :

Pair < String , String >[] a = new Pair < >[ N ];


- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Generiques . java :31: error : cannot create array with ’ <> ’
Pair < String , String >[] a = new Pair < >[ N ];

Pair < String , String >[] a = ( Pair < >[]) new Pair < String , String >[ N ];
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Programmation Java 641

Generiques . java :32: error : illegal start of type


Pair < String , String >[] a = ( Pair < >[]) new Pair < String , String >[ N ];

Pair < String , String >[] a = new Pair < String , String >[ N ];
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Generiques . java :42: error : generic array creation
Pair < String , String >[] a = new Pair < String , String >[ N ];

Les fragments de code suivants génèrent des avertissements de compilation :

Pair < String , String >[] a = ( Pair < String , String >[]) new Pair [ N ];
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Generiques . java :55: warning : [ unchecked ] unchecked cast
Pair < String , String >[] a = ( Pair < String , String >[]) new Pair [ N ];
^
required : Pair < String , String >[]
found : Pair []

Pair < String , String >[] a = new Pair [ N ];


- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Generiques . java :47: warning : [ unchecked ] unchecked conversion
Pair < String , String >[] a = new Pair [ N ];
^
required : Pair < String , String >[]
found : Pair []

On peut supprimer ces avertissements à l’aide d’une annotation appropriée de-


vant la méthode qui contient le fragment de code en question :
@SuppressWarnings ( " unchecked " )
void foo ( ... ) {
...
Pair < String , String >[] a = new Pair [ N ];
...
}
Programmation Java 642

12.E Exercices additionnels


12.E.1 Les différentes formes de pools de threads

Soit le segment de code suivant :


int n = 1000;
double [] a = new double [ n ];
for ( int i = 0; i < n ; i ++ ) {
a [ i ] = 1.0;
}

ExecutorService pool = ?XXXXXXXXXX? ;

double r
= sommation_pr (a , 0 , a . length -1 , 100 , pool );
System . out . println ( r );

Qu’est-ce qui sera imprimé par le segment de code ci-haut selon les différentes valeurs
possibles suivantes pour pool :

1. Executors.newCachedThreadPool()

2. new ForkJoinPool(4)

3. Executors.newFixedThreadPool(4)

Exercice 12.11: Méthodes foo_tranche et foo_pr.


Programmation Java 643

Programme Java 12.15 Méthodes foo_tranche et foo_pr.


public static double foo_tranche ( double [] a ,
int inf ,
int sup ) {
double somme = 0.0;
for ( int i = inf ; i <= sup ; i ++ ) {
somme += a [ i ];
}
return somme ;
}

public static double foo_pr ( double [] a ,


int inf , int sup ,
int seuil ,
ExecutorService pool ) {
if ( sup - inf + 1 <= seuil ) {
return foo_tranche (a , inf , sup );
}

int mid = ( sup + inf ) / 2;


Future < Double > gauche = pool . submit ( () ->
foo_pr (a , inf , mid , seuil , pool )
);
double droite = foo_pr (a , mid +1 , sup , seuil , pool );

try {
return gauche . get () + droite ;
} catch ( Exception e ) {
assert false : " *** Exception : " + e ;
return 0.0 D ;
}
}
Programmation Java 644

Soit le segment de code suivant :


int n = 100000;
double [] a = new double [ n ];
for ( int i = 0; i < n ; i ++ ) {
a [ i ] = 1.0;
}

ExecutorService pool = ?XXXXXXXXXX? ;

double r
= sommation_pr (a , 0 , a . length -1 , 2, pool );
System . out . println ( r );

Qu’est-ce qui sera imprimé par le segment de code ci-haut selon les différentes valeurs
possibles suivantes pour pool :

1. Executors.newCachedThreadPool()

2. new ForkJoinPool(4)

Exercice 12.12: Méthodes foo_tranche et foo_pr (bis).

Note : Dans l’exemple qui précède, on aurait pu utiliser gauche.get(), mais il


aurait alors fallu utiliser un try/catch.
Note : Il existe aussi une classe RecursiveAction, pour les tâches sans résultat —
donc résultat de type void.
Programmation Java 645

Programme Java 12.16 Sommation des éléments d’un tableau avec des
RecursiveTask et un ForkJoinPool.
class SommationRT extends RecursiveTask < Double > {
private double [] a ;
private int inf , sup , seuil ;

SommationRT ( double [] a , int inf , int sup , int seuil ) {


this . a = a ; this . inf = inf ;
this . sup = sup ; this . seuil = seuil ;
}

@Override
public Double compute () {
if ( sup - inf + 1 <= seuil ) {
return sommation_tranche (a , inf , sup );
}

// On decompose en deux sous - problemes .


int mid = ( sup + inf ) / 2;
SommationRT gaucheRT = new SommationRT (a , inf , mid , seuil );
SommationRT droiteRT = new SommationRT (a , mid +1 , sup , seuil );

// Tache pour sous - probleme gauche mais on garde droite .


gaucheRT . fork ();
double droite = droiteRT . compute ();

return gaucheRT . join () + droite ;


}
...

// Utilisation .
ForkJoinPool pool = new ForkJoinPool ( nbThreads );
SommationRT rt = new SommationRT (a , 0 , a . length -1 , seuil );
double r = pool . invoke ( rt );
Programmation Java 646

public abstract class RecursiveTask <V >


extends ForkJoinTask <V >
implements Future <V > {

protected abstract V compute ()


// The main computation performed by this task .
}

public abstract class ForkJoinTask <V >


implements Future <V > {
ForkJoinTask <V > fork ()
// Arranges to asynchronously execute this task
// in the pool the current task is running in ,
// if applicable , or using the ForkJoinPool . commonPool ()
// if not inForkJoinPool ().

V get ()
// Waits if necessary for the computation to complete ,
// and then retrieves its result .

V join ()
// Returns the result of the computation when it is done .
// This method differs from get () in that abnormal completion
// results in RuntimeException or Error , not ExecutionException ,
// and that interrupts of the calling thread do not cause
// the method to abruptly return by throwing InterruptedException .
}

Figure 12.2: Quelques méthodes des classe RecursiveTask et ForkJoinTask.


Programmation Java 647

12.E.2 Un moniteur pour des IStructures


Une I-structure est une forme de tableau composé d’un certain nombre de cellules,
où chaque cellule assure une synchronisation entre producteur (unique) et consom-
mateurs (multiples) de la cellule par l’intermédiaire d’opérations get et put : voir
l’interface IStructure dans le Programme Java 12.17.
Une classe concrète IStructureMonitor mettant en oeuvre cette interface est
présentée dans le Programme Java 12.18. La taille effective de la IStructure — le
nombre de cellules — est spécifiée au moment de l’allocation de la IStructure, dans
le constructeur. Les cellules d’une IStructure possèdent le comportement suivant :

• Initialement, toutes les cellules sont vides.

• Une cellule d’une IStructure peut être lue avec get. Toutefois, lorsque la
cellule est vide, le thread appelant est mis en attente jusqu’à ce que la cellule
ait été remplie par un autre thread.

• Une cellule d’une IStructure ne peut être écrite, avec put, qu’une seule et
unique fois. C’est une erreur (AlreadyFullException) d’écrire plus d’une
fois dans une cellule donnée d’une IStructure. Si des lecteurs sont attentes
au moment de l’écriture, ils sont réactivés de façon à obtenir la valeur qui
vient d’être écrite.

Le Programme Java 12.19 présente un petit cas de test, avec le processeur lecteur
(thread getter) mis en attente et réactivé lors de l’écriture par un autre thread.

Complétez la mise en oeuvre des méthodes put et get de la classe


IStructureMonitor, partiellement définie dans le Programme Java 12.17.
Exercice 12.13: Méthodes put et get des IStructureMonitor.
Programmation Java 648

Programme Java 12.17 Interface pour une IStructure.


interface IStructure <T > {
/* *
* Ecrit a la ieme position de la I - Structure .
*
* @param i Index
* @param v Valeur a ecrire
*
* @requires 0 <= i && i < taille de la I - Structure
*/
void put ( int i , T v )
throws AlreadyFullException ;

/* *
* Obtient le ieme element de la I - Structure .
*
* @requires 0 <= i && i < taille de la I - Structure
*
* @param i Index
* @return L ’ element a la position indiquee
*/
T get ( int i );
}

class AlreadyFullException
extends RuntimeException {}
Programmation Java 649

Programme Java 12.18 Une classe concrète IStructureMonitor qui met en oeu-
vre l’interface IStructure.
import java . util . concurrent . locks .*;

public class IStructureMonitor <T >


implements IStructure <T > {
private T [] elems ;
private ReentrantLock [] verrous ;
private Condition [] pleins ;

@SuppressWarnings ( " unchecked " )


public IStructureMonitor ( int n ) {
elems = ( T []) new Object [ n ];
verrous = new ReentrantLock [ n ];
pleins = new Condition [ n ];

for ( int i = 0; i < n ; i ++ ) {


elems [ i ] = null ;
verrous [ i ] = new ReentrantLock ();
pleins [ i ] = verrous [ i ]. newCondition ();
}
}

Programme Java 12.19 Un cas de test pour IStructureMonitor.


@Test public void exemple_exercice () {
IStructure < Integer > is
= new IStructureMonitor < Integer >(1);

Thread getter = new Thread ( () -> {


assertEquals ( ( Integer ) 10 , is . get (0) );
});
getter . start ();

new Thread ( () -> {


try { Thread . sleep (500); } catch ( Exception e ) {};
is . put ( 0 , 10 );
}). start ();

try { getter . join (); } catch ( Exception e ){}


}
Partie VI

Programmation parallèle par


mémoire distribuée

650
Chapitre 13

Processus et échanges de messages


⇒ transparents

Voir transparents à l’URL suivant :


http://www.labunix.uqam.ca/~tremblay/INF7235/Materiel/processus.pdf

651
Chapitre 14

Message Passing Interface (MPI)


⇒ transparents

Voir transparents à l’URL suivant :


http://www.labunix.uqam.ca/~tremblay/INF7235/Materiel/MPI.pdf

652
Chapitre 15

Exemples de programmes MPI

653
Exemples MPI 654

15.1 Petit pipeline avec trois processus


Il s’agit ici d’une version MPI du même programme vu en Ruby ainsi qu’en Go : le
premier processus génère une série de valeurs, le deuxième processus transforme ces
valeurs en les multipliant par 10, le troisième processus fait la somme des valeurs.

Le programme principal
# include < mpi .h >
# include < stdio .h >
# include < stdlib .h >
# include < math .h >
# include < assert .h >

// Tag utilise pour la transmission de donnees ou resultats


const int DONNEES = 0;

// Tag utilise pour signaler la fin du flux , lorsque necessaire


const int EOS = 1;

...

//
Exemples MPI 655

int main ( int argc , char * argv [] )


{
// On initialise MPI .
int numProc , nbProcs ;
MPI_Init ( & argc , & argv );
MPI_Comm_rank ( MPI_COMM_WORLD , & numProc );
MPI_Comm_size ( MPI_COMM_WORLD , & nbProcs );

// On << dispatche > > les trois processus , en plus du programme principal .
if ( numProc == 0 ) {
int x = 10;
MPI_Send ( &x , 1 , MPI_INT , 1 , DONNEES , MPI_COMM_WORLD );

MPI_Recv ( &x , 1 , MPI_INT , 3 , DONNEES , MPI_COMM_WORLD , MPI_STATUS_IGNORE )


printf ( " resultat = % d \ n " , x );
} else {
switch ( numProc ) {
case 1:
p1 ( 0 , 2 ); break ;
case 2:
p2 ( 1 , 3 ); break ;
case 3:
p3 ( 2 , 0 ); break ;
}
}

MPI_Finalize ();
return ( 0 );
}
Exemples MPI 656

Les trois processus


void p1 ( int input , int output )
{
// On recoit le nombre d ’ elements a generer .
int n ;
MPI_Recv ( &n , 1 , MPI_INT , input , DONNEES ,
MPI_COMM_WORLD , MPI_STATUS_IGNORE );

// On genere les elements , puis la fin du flux .


for ( int i = 1; i <= n ; i ++ ) {
MPI_Send ( &i , 1 , MPI_INT , output , DONNEES , MPI_COMM_WORLD );
}
MPI_Send ( NULL , 0 , MPI_BYTE , output , EOS , MPI_COMM_WORLD );
}

//
Exemples MPI 657

void p2 ( int input , int output )


{
int eos = 0;
while (! eos ) {
int v ;
MPI_Status statut ;
MPI_Recv ( &v , 1 , MPI_INT , input , MPI_ANY_TAG , MPI_COMM_WORLD ,
& statut );

if ( statut . MPI_TAG == DONNEES ) {


v *= 10;
MPI_Send ( &v , 1 , MPI_INT , output , DONNEES , MPI_COMM_WORLD );
} else {
assert ( statut . MPI_TAG == EOS );
MPI_Send ( NULL , 0 , MPI_BYTE , output , EOS , MPI_COMM_WORLD );
eos = 1;
}
}
}

//
Exemples MPI 658

void p3 ( int input , int output )


{
int r = 0;
int eos = 0;
while (! eos ) {
int v ;
MPI_Status statut ;
MPI_Recv ( &v , 1 , MPI_INT , input , MPI_ANY_TAG , MPI_COMM_WORLD ,
& statut );

if ( statut . MPI_TAG == DONNEES ) {


r += v ;
} else {
assert ( statut . MPI_TAG == EOS );
MPI_Send ( &r , 1 , MPI_INT , output , DONNEES , MPI_COMM_WORLD );
eos = 1;
}
}
}
Exemples MPI 659

15.2 Intégration numérique


// / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
//
// Fonction pour integration numerique a l ’ aide d ’ une quadrature
// composite utilisant la methode des trapezes .
//
// Arguments :
// - f : la fonction a integrer
// - a : la borne inferieure de l ’ intervalle
// - b : la borne superieure de l ’ intervalle
// - nbIntervalles : le nombre d ’ intervalles a utiliser
//
//
// Resultat :
// - Doit etre disponible sur le processeur 0 uniquement .
//
// / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /

//
Exemples MPI 660

double integrer ( double (* f )( double ) ,


double a , double b ,
int nbIntervalles )
{
int numProc , nbProcs ;
MPI_Comm_rank ( MPI_COMM_WORLD , & numProc );
MPI_Comm_size ( MPI_COMM_WORLD , & nbProcs );

// Chaque processus determine ses index inferieur et superieur .


int inf = numProc * ( nbIntervalles / nbProcs );
int sup = inf + ( nbIntervalles / nbProcs );
if ( numProc == ( nbProcs -1) ) {
sup = nbIntervalles ; // Cas special pour dernier processeur , si pas divi
}

// Chaque processus fait la sommation sur son sous - intervalle .


double h = ( b - a ) / ( double ) nbIntervalles ; // Taille du pas d ’ integrati
double sommeLocale = 0.0;
for ( int i = inf ; i < sup ; i ++ ) {
double gauche = a + i * h ;
double droite = gauche + h ;
sommeLocale += ( f ( gauche ) + f ( droite ) ) * h / 2;
}

// On combine les resultats intermediaires .


double sommeTotale ;
MPI_Reduce ( & sommeLocale , & sommeTotale , 1 , MPI_DOUBLE ,
MPI_SUM , 0 , MPI_COMM_WORLD );

return sommeTotale ;
}
Exemples MPI 661

15.3 Fonction mystere


// / // / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
// Arguments :
// - elems : tableau d ’ entiers
// - nb : taille de elems
// - x : un entier
//
// On suppose que le tableau elems est initialement conserve sur le
// processeur 0 , et on veut que la reponse finale soit connue sur le
// processeur 0.
//
// / // / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
int mystere ( int elems [] , int nb , int x )
{
// On repartit les donnees .
int nbProcs ;
MPI_Comm_size ( MPI_COMM_WORLD , & nbProcs );

int monNb = nb / nbProcs ;


int * mesElems = ( int *) malloc ( monNb * sizeof ( int ) );
MPI_Scatter ( elems , monNb , MPI_INT ,
mesElems , monNb , MPI_INT ,
0 , MPI_COMM_WORLD );

// On traite les donnees locales .


int r = 0;
for ( int i = 0; i < monNb ; i ++ ) {
if ( mesElems [ i ] == x ) { r += 1; }
}

// On calcule le resultat final .


int rGlobal ;
MPI_Reduce ( &r , & rGlobal , 1 , MPI_INT , MPI_SUM , 0 , MPI_COMM_WORLD );
return rGlobal ;
}
Exemples MPI 662

15.4 Programme pour simuler la diffusion de la chaleur


dans un cylindre
Remarque : Les programmes qui suivent sont tirés textuellement du livre «Pat-
terns for Parallel Programming» (T.G. Mattson, B.A. Sanders and B.L. Massingill,
Addison-Wesley, 2005). Les seules différences sont l’ajout de quelques commentaires
explicatifs et certaines petites modifications pour assurer une mise en page correcte.

15.4.1 Solution purement séquentielle


/* * Solution sequentielle au probleme de la diffusion de la chaleur
dans un cylindre .

La formule utilisee pour l ’ equation de diffusion est differente ,


mais equivalente , a celle vue dans mon document " Resolution
numerique de l ’ equation de diffusion de la chaleur dans un
cylindre ":

NX : Nombre de points
uk : vecteur des temperatures au temps t
ukp1 : vecteur des temperatures au temps t plus 1
1.0: " longueur " du cylindre

Posons :
dx = 1.0 / NX
dt = 0.5 * dx * dx

Alors :

ukp1 [ i ]
= uk [ i ] + dt / ( dx * dx ) * ( uk [ i +1] - 2 * uk [ i ] + uk [i -1])
= uk [ i ] + (0.5 * dx * dx )/( dx * dx ) * ( uk [ i +1] - 2 * uk [ i ] + uk [i -1])
= uk [ i ] + 0.5 * ( uk [ i +1] - 2 * uk [ i ] + uk [i -1])
= uk [ i ] + 0.5 * uk [ i +1] - 0.5 * 2 * uk [ i ] + 0.5 * uk [i -1]
= uk [ i ] + 0.5 * uk [ i +1] - uk [ i ] + 0.5 * uk [i -1]
= 0.5 * uk [ i +1] + 0.5 * uk [i -1]
= ( uk [ i +1] + uk [i -1] ) / 2
*/
Exemples MPI 663

# include < mpi .h >


# include < stdio .h >
# include < stdlib .h >
# include < math .h >

# define NX (1000*16)
# define LEFTVAL 1.0
# define RIGHTVAL 10.0
# define NSTEPS 10000

void initialize ( double uk [] , double ukp1 [] )


{
// On initialise la grille des temperatures courantes
uk [0] = LEFTVAL ;
uk [ NX -1] = RIGHTVAL ;
for ( int i = 1; i < NX -1; i ++ ) {
uk [ i ] = 0.0;
}

// On fait de meme pour l ’ autre grille


// ( au temps suivant : p1 = > plus 1)
for ( int i = 0; i < NX ; i ++ ) {
ukp1 [ i ] = uk [ i ];
}
}

void printValues ( double uk [] , int step )


{
printf ( " uk {% d }::\ n " , step );
for ( int i = 0; i < NX ; i ++ ) {
printf ( " %6.2 f " , uk [ i ] );
}
printf ( " \ n " );
}
Exemples MPI 664

int main ( int argc , char * argv [] )


{
double * uk = malloc ( sizeof ( double ) * NX );
double * ukp1 = malloc ( sizeof ( double ) * NX );
double * temp ;

double dx = 1.0 / NX ;
double dt = 0.5 * dx * dx ;

initialize ( uk , ukp1 );

for ( int k = 0; k < NSTEPS ; k ++ ) {


ukp1 [0] = uk [0];
ukp1 [ NX -1] = uk [ NX -1];
for ( int i = 1; i < NX -1; i ++ ) {
ukp1 [ i ] =
uk [ i ] + ( dt /( dx * dx )) * ( uk [ i +1] - 2* uk [ i ] + uk [i -1]);
}

// On interchange les deux grilles .


temp = ukp1 ; ukp1 = uk ; uk = temp ;
}

// On affiche la grille finale .


printValues ( uk , NSTEPS );

return ( 0 );
}
Exemples MPI 665

15.4.2 Solution parallèle avec communications synchrones


void initialize ( double uk [] , double ukp1 [] ,
int numPoints , int numProcs , int myID )
{
// On initialise la grille des temperatures courantes ...
// pour les points non - extremes .
for ( int i = 1; i <= numPoints ; i ++ ) {
uk [ i ] = 0.0;
}

// Cas speciaux : les deux extremites .


if ( myID == 0) uk [1] = LEFTVAL ;
if ( myID == numProcs -1) uk [ numPoints ] = RIGHTVAL ;

// On fait de meme pour l ’ autre grille


// ( au temps suivant : p1 = > plus 1)
for ( int i = 1; i <= numPoints ; i ++ ) {
ukp1 [ i ] = uk [ i ];
}
}
Exemples MPI 666

int main ( int argc , char * argv [] )


{
double * uk , * ukp1 ;
double * temp ;

double dx = 1.0 / NX ;
double dt = 0.5 * dx * dx ;

int numProcs , myID , leftNbr , rightNbr , numPoints ;


MPI_Status status ;

MPI_Init ( & argc , & argv );


MPI_Comm_rank ( MPI_COMM_WORLD , & myID );
MPI_Comm_size ( MPI_COMM_WORLD , & numProcs );

leftNbr = myID -1;


rightNbr = myID +1;
numPoints = NX / numProcs ;

uk = malloc ( sizeof ( double ) * ( numPoints +2) );


ukp1 = malloc ( sizeof ( double ) * ( numPoints +2) );

initialize ( uk , ukp1 , numPoints , numProcs , myID );


Exemples MPI 667

for ( int k = 0; k < NSTEPS ; ++ k ) {


// On obtient les donnees aux frontieres ( cellules fantomes ).
if ( myID != 0)
MPI_Send ( & uk [1] , 1 , MPI_DOUBLE , leftNbr , 0 ,
MPI_COMM_WORLD );
if ( myID != numProcs -1)
MPI_Send ( & uk [ numPoints ] , 1 , MPI_DOUBLE , rightNbr , 0 ,
MPI_COMM_WORLD );

if ( myID != 0)
MPI_Recv ( & uk [0] , 1 , MPI_DOUBLE , leftNbr , 0 ,
MPI_COMM_WORLD , & status );
if ( myID != numProcs -1)
MPI_Recv ( & uk [ numPoints +1] , 1 , MPI_DOUBLE , rightNbr , 0 ,
MPI_COMM_WORLD , & status );

// On calcule les points intermediaires .


for ( int i = 2; i < numPoints ; ++ i ) {
ukp1 [ i ] = uk [ i ] + ( dt /( dx * dx )) * ( uk [ i +1] - 2* uk [ i ] + uk [i -1]);
}

// On traite les extremites ... si necessaire .


if ( myID != 0) {
int i = 1;
ukp1 [ i ] = uk [ i ]+( dt /( dx * dx ))*( uk [ i +1] -2* uk [ i ]+ uk [i -1]);
}
if ( myID != numProcs -1) {
int i = numPoints ;
ukp1 [ i ] = uk [ i ]+( dt /( dx * dx ))*( uk [ i +1] -2* uk [ i ]+ uk [i -1]);
}

temp = ukp1 ; ukp1 = uk ; uk = temp ; // On interchange les grilles .


}
Exemples MPI 668

MPI_Finalize ();

printValues ( uk , NSTEPS , numPoints , myID );


return ( 0 );
}
Exemples MPI 669

15.4.3 Solution parallèle avec communications asynchrones


for ( int k = 0; k < NSTEPS ; ++ k ) {
// On obtient les communications pour les donnees aux frontieres .
if ( myID != 0) {
MPI_Irecv ( & uk [0] , 1 , MPI_DOUBLE , leftNbr , 0 , MPI_COMM_WORLD , & reqRecvL )
MPI_Isend ( & uk [1] , 1 , MPI_DOUBLE , leftNbr , 0 , MPI_COMM_WORLD , & reqSendL )
}
if ( myID != numProcs -1) {
MPI_Irecv ( & uk [ numPoints +1] , 1 , MPI_DOUBLE , rightNbr , 0 , MPI_COMM_WORLD ,
MPI_Isend ( & uk [ numPoints ] , 1 , MPI_DOUBLE , rightNbr , 0 , MPI_COMM_WORLD , & r
}

// On calcule les points intermediaires .


for ( int i = 2; i < numPoints ; ++ i ) {
ukp1 [ i ] = uk [ i ] + ( dt /( dx * dx )) * ( uk [ i +1] - 2* uk [ i ] + uk [i -1]);
}

// On s ’ assure de la disponibilites des valeurs aux frontieres .


if ( myID != 0) {
MPI_Wait ( & reqRecvL , & status );
MPI_Wait ( & reqSendL , & status );
}
if ( myID != numProcs -1) {
MPI_Wait ( & reqRecvR , & status );
MPI_Wait ( & reqSendR , & status );
}

// On traite les extremites ... si necessaire .


if ( myID != 0) {
int i = 1;
ukp1 [ i ] = uk [ i ] + ( dt /( dx * dx )) * ( uk [ i +1] - 2* uk [ i ] + uk [i -1]);
}
if ( myID != numProcs -1) {
int i = numPoints ;
ukp1 [ i ] = uk [ i ] + ( dt /( dx * dx )) * ( uk [ i +1] - 2* uk [ i ] + uk [i -1]);
}

temp = ukp1 ; ukp1 = uk ; uk = temp ; // On interchange les grilles .


}
Chapitre 16

Approche PCAM de Foster ⇒


transparents

Voir transparents à l’URL suivant :


http://www.labunix.uqam.ca/~tremblay/INF7235/Materiel/foster.pdf

670
Chapitre 17

Exécution et débogage de
programmes parallèles MPI avec
OpenMPI

671
Débogage de programmes MPI 672

17.1 Introduction : Caractéristiques d’un cluster


et du cluster utilisé dans le cours
Voici deux définitions de ce qu’est un cluster :

Co-located collection of mass-produced computers and switches dedicated to


running parallel jobs. The computers typically do not have displays or key-
boards [and] some of the computers may not allow users to login [except head
node]. All computers run the same OS and have identical disk images. [They
also] use a faster, [generally] switched networked, e.g., gigabit Ethernet. [Qui03]
Any collection of distinct computers that are connected and used as a parallel
computer, or to form a redundant system for higher availability. The com-
puters in a cluster are not specialized to cluster computing. In other words,
the computer making up the cluster [. . . ] are not custom-built for use in the
cluster. [MSM05].

Un cluster — une grappe de processeurs, une grappe de calcul — est donc une machine
parallèle MIMD de type multi-ordinateurs ayant les caractéristiques suivantes :

• Les diverses machines — les divers «noeuds» — sont intégrées en un système unique,
(généralement) avec un réseau dédié.

• Les noeuds travaillent sur un seul et même problème.

• Les divers noeuds sont des machines de «faible coût», où chaque noeud «pourrait»
en théorie être utilisé comme une machine ou station de travail normale. Toutefois,
en pratique, généralement un seul des noeuds — le «head node» — est accessible
directement aux usagers.
Débogage de programmes MPI 673

La machine utilisée dans le cours INF7235


Le cluster que nous allons utiliser pour la partie du cours sur MPI est la machine turing.hpc.uqam.ca
du LAMISS.
Cette machine, acquise en 2014, possède les caractéristiques suivantes :

• 1 head node — turing.hpc.uqam.ca

• 30 noeuds — quark20.hpc.uqam.ca, . . . , quark49.hpc.uqam.ca


– Chaque noeud compte 4 processeurs Intel Xeon x86_64

• Système d’exploitation : Scientific Linux release 6.7.


Débogage de programmes MPI 674

17.2 Étapes pour l’exécution d’un programme MPI


Pour compiler et exécuter un programme MPI/C avec OpenMPI (version MPI installée
sur le cluster, deux étapes sont nécessaires, décrites plus bas. Notez toutefois que des
cibles make appropriées ont été définies pour les laboratoires, donc il suffit généralement
d’exécuter «make compile» puis «make run».

1. On compile le programme sur le noeud maître (headnode). Par exemple :

$ mpicc -o hello -std=c99 hello.c

2. On lance l’exécution du programme en spécifiant le nombre de «processeurs» qu’on


désire utiliser, avec la commande mpirun— il s’agit possiblement de «processeurs
virtuels» si la valeur indiquée est supérieure au nombre de noeuds. Par exemple :

$ mpirun -np 5 hello


quark20.hpc.uqam.ca
numProc = 0, nbProcs = 5

quark21.hpc.uqam.ca
numProc = 1, nbProcs = 5

quark22.hpc.uqam.ca
numProc = 2, nbProcs = 5

quark23.hpc.uqam.ca
numProc = 3, nbProcs = 5

quark24.hpc.uqam.ca
numProc = 4, nbProcs = 5
Débogage de programmes MPI 675

Signalons que n’importe quel programme ou commande peut être exécuté avec
mpirun :

$ mpirun -np 3 date


ven dec 18 14:35:02 EST 2015
ven dec 18 14:35:02 EST 2015
ven dec 18 14:35:02 EST 2015

$ mpirun -np 2 ls -l hello


-rwxr-xr-x 1 tremblay users 9542 18 dec 14:33 hello
-rwxr-xr-x 1 tremblay users 9542 18 dec 14:33 hello

$ mpirun -np 2 uname -nmpo


quark20.hpc.uqam.ca x86_64 x86_64 GNU/Linux
quark21.hpc.uqam.ca x86_64 x86_64 GNU/Linux

$ mpirun -np 6 uname -n


quark21.hpc.uqam.ca
quark20.hpc.uqam.ca
quark23.hpc.uqam.ca
quark24.hpc.uqam.ca
quark22.hpc.uqam.ca
quark25.hpc.uqam.ca
Débogage de programmes MPI 676

17.3 Stratégies pour déboguer un programme parallèle


This brings up one of the most important points to keep up in mind when
you’re debugging a parallel program: Many (if not most) parallel program bugs
have nothing to do with the fact that the program is a parallel program. Many
(if not most) parallel program bugs are caused by the same mistakes that cause
serial program bugs. [Pac97]

Pour déboguer un programme parallèle, il est généralement préférable de procéder, en


gros, comme suit :

a. On vérifie tout d’abord le bon fonctionnement du programme pour un unique pro-


cessus s’exécutant sur un unique processeur.

b. On vérifie le bon fonctionnement pour deux ou plusieurs processus mais s’exécutant


sur un seul et unique processeur — voir plus bas.

c. On vérifie le bon fonctionnement du programme pour deux ou plusieurs processus


s’exécutant sur deux processeurs.

d. On vérifie le bon fonctionnement avec plusieurs processus s’exécutant sur plusieurs


processeurs.

Autres trucs :

• Lorsqu’on utilise des printf pour générer une trace d’exécution, il est préférable de
mettre une instruction fflush immédiatement après le printf, pour assurer que les
impressions se fassent dans un ordre qui reflète le plus possible l’exécution réelle :

fflush( stdout );

Il faut aussi savoir que les programmes parallèles peuvent parfois contenir des Heisen-
bug. Plus précisément, il peut arriver qu’un programme parallèle ne fonctionne pas
— par exemple, à cause d’erreurs de synchronisation entre processus — mais que
le programme fonctionne sans problème quand on rajoute des instructions printf
pour tenter de le déboguer!1
Lorsqu’on utilise des traces d’exécution, il faut aussi tenir compte du fait que la
quantité totale d’information générée sera multipliée par le nombre de processeurs
— ce qui peut parfois rendre difficile de comprendre les informations ainsi produites.
1
On parle de Heisenbug car l’effet d’observer le programme modifie son comportement, dans
l’esprit du principe d’incertitude d’Heisenberg en physique.
Débogage de programmes MPI 677

• Toujours pour la génération de traces d’exécution avec printf, il est préférable


d’indiquer explicitement le numéro du processus générant un élément de la trace. Si
ce numéro est mis (en préfixe) au début de la ligne, alors on peut ensuite utiliser
l’outil Unix sort pour produire une liste exacte de ce qui est imprimé par chaque
processus — voir aussi plus bas pour une façon rapide d’indiquer les numéros de
processus avec l’option d’exécution «-l».

• Lorsqu’une erreur d’exécution est générée par MPI, le message contient généralement
le numéro du processeur (virtuel) et autres informations. Par exemple, le message
suivant a été produit par le processeur 0 :

mpirun has exited due to process rank 0 with PID 32099 on


node quark20 exiting improperly. There are two reasons this could occur:
[...]
Débogage de programmes MPI 678

17.4 Erreurs typiques dans des programmes MPI


Note : Les deux premières sous-sections sont une traduction (partielle) de l’appendice C
du livre de M.J. Quinn [Qui03].

17.4.1 Erreurs conduisant à des interblocages (deadlock )


• Un seul processus exécute un opération de communication collective (e.g., MPI_Reduce,
MPI_Bcast, MPI_Scatter).
Une solution à ce problème est de ne jamais mettre des opérations de communication
collective dans du code s’exécutant conditionnellement.
• Deux ou plusieurs processus essaient d’échanger de l’information, mais en utilisant
MPI_Recv, et ce avant que des appels appropriés à MPI_Send aient été effectués.
Différentes solutions sont possibles, par exemple : toujours s’assurer d’effectuer les
MPI_Send avant les MPI_Recv ; utiliser plutôt des appels à l’opération MPI_Sendrecv ;
utiliser des appels à MPI_Irecv.
• Un processus essaie de recevoir des données d’un processeur, qui n’effectue jamais
d’envoi approprié.
Ceci est souvent causé par l’utilisation d’un mauvais numéro de processus. Lorsque
possible, il est préférable d’utiliser les opérations collectives de communication. Si ce
n’est pas possible, il faut s’assurer que le «patron» des échanges entre les processus
soit le plus simple possible.
• Un processus essaie de recevoir des données de lui-même.

17.4.2 Erreurs conduisant à des résultats erronnés


• Incohérence au niveau des types utilisés dans l’opération Send vs. Recv.
Pour éviter ce problème, il faut s’assurer au niveau du code source que chaque opéra-
tion d’envoi possède une unique opération de réception correspondante, et vérifier
(et re-vérifier) le code source.
• Arguments d’un appel à une opération de communication transmis dans le mauvais
ordre.

17.4.3 Comportements non spécifiés de MPI


• «The MPI specification purposefully does not mandate whether or not collective com-
munication operations have the side effect of synchronizing the processes over which
they operate.»
Donc, des opérations telles que MPI_Bcast, MPI_Reduce, etc., peuvent, ou non, créer
une barrière de synchronisation entre les processus impliqués.
Débogage de programmes MPI 679

• «According to the MPI standard, message buffering may or may not occur when
processes communicate with each other using MPI_Send.»
Cette question sera abordée ultérieurement.
Débogage de programmes MPI 680

17.5 Options d’exécution avec OpenMPI


Divers options peuvent être spécifiés à mpirun pour aider à déboguer un programme :

• On peut demander, avec l’option «--tag-output», que chaque impression sur stdout
soit automatiquement préfixée du numéro du processus : voir figure 17.1.
Idem pour «--timestamp-output», mais un timestamp est aussi affiché.

• On peut exécuter le programme en créant plusieurs processus, mais sur un noeud


unique, ou sur un petit nombre de noeuds. Pour ce faire, il faut lancer l’exécution
avec mpirun avec une liste de des noeuds à utiliser avec l’option «--host», tel
qu’illustré à la figure 17.2.
Il est aussi possible d’utiliser l’option «--hostfile», et dans ce cas on indique le
nom d’un fichier qui spécifie les divers noeuds à utiliser.

• L’option «--mca mpi_param_check 1» permet que certaines vérifications soient ef-


fectuées en cours d’exécution.
Par exemple, soit une communication réalisée avec les deux instructions suivantes
exécutées par deux processus distincts :

int signal;
MPI_Send( &signal, 1, MPI_TYPE_NULL, 1, 0, MPI_COMM_WORLD );
...................
int signal;
MPI_Recv( &signal, 1, MPI_INT, MPI_ANY_SOURCE, 0, MPI_COMM_WORLD, &statut );

La Figure 17.3 montre l’effet d’exécuter cette communication. La troisième exécution


montre aussi. . . que cette option est activée par défaut dans OpenMPI.
Débogage de programmes MPI 681

Sans option --tag-output :


===========================

$ mpirun -np 3 hello


quark20.hpc.uqam.ca
numProc = 0, nbProcs = 3

quark21.hpc.uqam.ca
numProc = 1, nbProcs = 3

quark22.hpc.uqam.ca
numProc = 2, nbProcs = 3

---------------------------------------

Avec l’option --tag-output :


============================

$ mpirun -np 3 --tag-output hello


[1,0]<stdout>:quark20.hpc.uqam.ca
[1,0]<stdout>: numProc = 0, nbProcs = 3
[1,0]<stdout>:
[1,1]<stdout>:quark21.hpc.uqam.ca
[1,1]<stdout>: numProc = 1, nbProcs = 3
[1,1]<stdout>:
[1,2]<stdout>:quark22.hpc.uqam.ca
[1,2]<stdout>: numProc = 2, nbProcs = 3
[1,2]<stdout>:
[1,0]<stdout>:---------------------------------------

Figure 17.1: Utilisation des options «--tag-output» pour indiquer le numéro du


processus sur les lignes de sortie. Le premier nombre indique le process jobid alors
que le deuxième indique le rang du procesus dans le communicateur.
Débogage de programmes MPI 682

$ mpirun -np 5 uname -no


quark21.hpc.uqam.ca GNU/Linux
quark23.hpc.uqam.ca GNU/Linux
quark20.hpc.uqam.ca GNU/Linux
quark24.hpc.uqam.ca GNU/Linux
quark22.hpc.uqam.ca GNU/Linux

$ mpirun --host quark21,quark25 -np 5 uname -no


quark25.hpc.uqam.ca GNU/Linux
quark21.hpc.uqam.ca GNU/Linux
quark21.hpc.uqam.ca GNU/Linux
quark25.hpc.uqam.ca GNU/Linux
quark21.hpc.uqam.ca GNU/Linux

Figure 17.2: Création de plusieurs processus sur un noeud unique ou sur un groupe
limité de noeuds. Cet exemple illustre aussi le fait que le programme exécuté par
mpirun peut aussi être une simple commande Unix.
Débogage de programmes MPI 683

// Processus 0
int signal ;
MPI_Send (& signal , 1 , MPI_DATATYPE_NULL , 1 , 0 , MPI_COMM_WORLD );

// Processus 1
int signal ;
MPI_Recv (& signal , 1 , MPI_INT , MPI_ANY_SOURCE , 0 , MPI_COMM_WORLD , & st );

===========================================================================

$ mpirun -np 3 −−mca mpi_param_check 0 hello


quark20.hpc.uqam.ca
numProc = 0, nbProcs = 3

quark22.hpc.uqam.ca
numProc = 2, nbProcs = 3

quark21.hpc.uqam.ca
numProc = 1, nbProcs = 3

===========================================================================

$ mpirun -np 3 −−mca mpi_param_check 1 hello


quark20.hpc.uqam.ca
numProc = 0, nbProcs = 3

[quark20.hpc.uqam.ca:32099] *** An error occurred in MPI_Send


[quark20.hpc.uqam.ca:32099] *** on communicator MPI_COMM_WORLD
[quark20.hpc.uqam.ca:32099] *** MPI_ERR_TYPE: invalid datatype
[quark20.hpc.uqam.ca:32099] *** MPI_ERRORS_ARE_FATAL: your MPI job will now abort
--------------------------------------------------------------------------
mpirun has exited due to process rank 0 with PID 32099 on
node quark20 exiting improperly. [...]

Figure 17.3: Effet de certaines vérifications effectuées par mpi_param_check. Note :


Par défaut, dans les versions récentes d’OpenMPI, mpi_param_check est 1, donc pas
besoin de le spécifier explicitement. (MCA = Modular Component Architecture)
Chapitre 18

Produit parallèle de matrices

18.1 Introduction
Ce chapitre présente trois stratégies pour effectuer le produit de matrices dans un contexte
de programmation parallèle par échange de messages. Ces trois stratégies reposent sur une
distribution des données par blocs.

684
Produit parallèle de matrices 685

18.2 Distribution des données par bloc


Pourquoi une distribution par blocs
Les explications motivant l’approche de distribution par blocs ont été données en cours.
Elles reposent sur le fait que le ratio coûts des communication / coûts des calculs est plus
intéressant dans le cas de la distribution par blocs, donc le programme résultant est plus
«dimensionable» («scalable»).
Ainsi, soit p le nombre de processeurs — un carré parfait :

• Approche par groupe de lignes adjacentes : Le ratio est proportionnel à p.



• Approche par blocs : Le ratio est proportionnel à p.

En d’autres mots, avec une distribution par blocs, les coûts des communications aug-
mentent moins rapidement lorsqu’on augmente le nombre de processeurs — en fonction de

p plutôt qu’en fonction de p (augmentation linéaire).

Illustration de la distribution d’une matrice 3 × 3


On veut multiplier deux matrices A et B pour obtenir une matrice C. L’idée de base
des stratégies est présentée avec une petite matrice 3 × 3. Toutefois, la définition du
produit matriciel s’applique que les éléments des matrices — les ai,j , bi,j et ci,j — soient
des nombres. . . ou des sous-matrices :
 
a0,0 a0,1 a0,2
A = a1,0 a1,1 a1,2 
a2,0 a2,1 a2,2
 
b0,0 b0,1 b0,2
B = b1,0 b1,1 b1,2 
b2,0 b2,1 b2,2
P2 P2 P2 
k=0 a0,k ∗ bk,0 k=0 a0,k ∗ bk,1 k=0 a0,k ∗ bk,2
C = A ∗ B =  2k=0 a1,k ∗ bk,0
P P2 P2
a1,k ∗ bk,1 a1,k ∗ bk,2 
P2 Pk=0
2 Pk=0
2
k=0 a2,k ∗ bk,0 k=0 a2,k ∗ bk,1 k=0 a2,k ∗ bk,2

La figure 18.1 présente une décomposition en neuf (9) «blocs» pour neuf (9) processus
d’une matrice 3 × 3, où chaque bloc indique les produits qui doivent être effectués et
additionnés pour calculer le résultat de Cij dont est responsable le processus Pij — pour
une matrice de plus grande taille, il suffirait d’intepréter les aij comme des sous-matrices,
i.e., de vrais «gros blocs» d’éléments.
Plus précisément, le processus Pij est «responsable» des éléments d’index i, j de cha-
cune des matrices, i.e., il doit initialement stocker les éléments appropriés des matrices de
départ A et B, et calculer/stocker l’élément correspondant de la matrice résultat C. Dans
Produit parallèle de matrices 686

la figure 18.1, les cercles en pointillés indiquent l’élément a et l’élément b stockés par le
processus Pij ; pour tous les autres éléments, des communications avec d’autres processus
sont donc nécessaires.

Idée générale des stratégies


L’idée génerale derrière toutes les stratégies étudiées est d’effectuer les différents produits et
sommes requis en différentes phases. Ces phases peuvent être comprises en organisant les
boucles de calcul du produit matriciel d’une façon différente de celle utilisée habituellement.
Soit les matrices suivantes pour lesquelles ont veut calculer C = A × B :

• A:N ×P

• B :P ×M

• C :N ×M

La figure 18.2 présente la solution «standard» habituelle. Les figures 18.3 et 18.4,
quant à elles, présentent une solution alternative où les boucles ont été organisées de façon
différente — la boucle la plus interne est devenue la boucle externe.
Produit parallèle de matrices 687

Figure 18.1: Matrice résultat C : calculs (produits et sommes) à effectuer pour une
matrice 3×3 (avec 9 blocs, pour 9 processus). Les items en pointillés indiquent des
éléments qui sont locaux au processus, à cause de la distribution par blocs. Tous
les autres items sont non-locaux, donc doivent être obtenus via des communications.
Produit parallèle de matrices 688

// C = A * B
//
// A: N × P
// B: P × M
// C: N × M

for ( int i = 0; i < N ; i ++ ) {


for ( int j = 0; j < M ; j ++ ) {
C [i , j ] = 0.0;
for ( int k = 0; k < P ; k ++ ) {
C [i , j ] += A [i , k ] * B [k , j ];
}
}
}

Figure 18.2: Solution «standard» pour le produit matriciel.

// C = A * B
//
// A: N × P
// B: P × M
// C: N × M

for ( int i = 0; i < N ; i ++ ) {


for ( int j = 0; j < M ; j ++ ) {
C [i , j ] = 0.0;
}
}

for ( int k = 0; k < P ; k ++ ) {


for ( int i = 0; i < N ; i ++ ) {
for ( int j = 0; j < M ; j ++ ) {
C [i , j ] += A [i , k ] * B [k , j ];
}
}
}

Figure 18.3: Solution «alternative» pour le produit matriciel avec réorganisation


des boucles — la boucle interne devient la boucle externe.
Produit parallèle de matrices 689

// C = A * B
//
// A: N × P
// B: P × M
// C: N × M

void initialize ( double C [][] , int N , int M ,


double v ) {
for ( int i = 0; i < N ; i ++ )
for ( int j = 0; j < M ; j ++ )
C [i , j ] = v ;
}

void matmul_add ( int k , double A [][] , double B [][] ,


double C [][] , int N , int M ) {
for ( int i = 0; i < N ; i ++ ) {
for ( int j = 0; j < M ; j ++ ) {
C [i , j ] += A [i , k ] * B [k , j ];
}
}
}

//
initialize ( C , N , M , 0.0 );

for ( int k = 0; k < P ; k ++ ) {


matmul_add ( k , A , B , C , N , M );
}

Figure 18.4: Solution «alternative» pour le produit matriciel avec réorganisation


des boucles — la boucle interne devient la boucle externe — et avec utilisation
de procédures auxiliaires initialize et matmul_add.
Produit parallèle de matrices 690

18.3 Méthode de Mattson, Sanders et Massingill [MSM05]


Ordre des calculs et communications

Figure 18.5: Ordre de calcul des produits dans la méthode de Mattson, Sanders et
Massingil [MSM05] (voir code plus loin).

La figure 18.5 illustre quels calculs (produits) sont effectués à chacune des étapes (k =
0, 1, 2) du programme MPI vu en cours (adaptation de [MSM05]). Dans cette méthode de
calcul, chacun des processus Pij effectue, à l’étape k (k = 0, 1, . . . , n − 1), le produit de
aik · bkj .
La figure 18.6, quant à elle, indique les communications qui doivent être effectuées
avant le début des calculs de la zéroième étape, pour assurer que les dépendances de don-
Produit parallèle de matrices 691

nées soient correctement satisfaites — une figure équivalente, décalée de façon appropriée,
pourrait être produite pour les étapes 1 et 2. Les items entourés d’un petit rond sont
ceux qui doivent être transmis, par le processus Pij . Les figures qui suivent illustrent les
communications aux étapes subséquentes.

Figure 18.6: Communications à effectuer au début de l’étape numéro zéro (0),


pour que les données requises soient disponibles (méthode Mattson, Sanders et
Massingil [MSM05]).
Produit parallèle de matrices 692

Figure 18.7: Communications à effectuer au début de l’étape numéro un (1),


pour que les données requises soient disponibles (méthode Mattson, Sanders et
Massingil [MSM05]).
Produit parallèle de matrices 693

Figure 18.8: Communications à effectuer au début de l’étape numéro deux (2),


pour que les données requises soient disponibles (méthode Mattson, Sanders et
Massingil [MSM05]).
Produit parallèle de matrices 694

Code MPI — fichier mm-bcast.c


Pour la misen en oeuvre MPI, on va attribuer aux divers processus deux IDs additionnels,
relatifs à une grille, qui vont indiquer la position du processus en terme du numéro de ligne
ou de colonne dans la grille.

Par exemple, soit :

• numProcs = 9 — nombre de processeurs

• NB = 3 — nombre de blocs par ligne ou colonne

• myID = rang du processus dans MPI_COMM_WORLD

• myID_i = myID / NB

• myID_j = myID % NB

myID myID_i myID_j


P0 0 0 0
P1 1 0 1
P2 2 0 2
P3 3 1 0
P4 4 1 1
P5 5 1 2
P6 6 2 0
P7 7 2 1
P8 8 2 2

P0 P1 P2
P3 P4 P5
P6 P7 P8
Soit alors les instructions MPI_Comm_split suivantes :
MPI_Comm lignes , colonnes ;

MPI_Comm_split ( MPI_COMM_WORLD , myID_i , MPI_UNDEFINED ,


& lignes );

MPI_Comm_split ( MPI_COMM_WORLD , myID_j , MPI_UNDEFINED ,


& colonnes );
Ces instructions créeront alors les communicateurs ci-bas.
Produit parallèle de matrices 695

MPI_Comm_split( MPI_COMM_WORLD, myID_i, MPI_UNDEFINED, &lignes );

MPI_Comm_split( MPI_COMM_WORLD, myID_j, MPI_UNDEFINED, &colonnes );

Le fichier suivant donne le code MPI pour cette approche :


http://www.labunix.uqam.ca/~tremblay/INF7235/Materiel/mult-mat-par.c

Le code est présenté dans les pages qui suivent.


Produit parallèle de matrices 696

/*
* Solution parallele pour le produit de matrices , avec allocation
* dynamique des matrices et distribution par blocs .
*
* Chaque matrice est allouee sous forme d ’ un << vecteur > > lineaire de
* la taille appropriee . Les diverses routines doivent donc manipuler
* explicitement les pointeurs plutot que de faire des simples
* operations d ’ indexation .
*
* Source : Le code programme est essentiellement tire de " Patterns for
* Parallel Programming " , T . G . Mattson , B . A . Sanders and
* B . L . Massingill , Addison - Wesley , 2005.
*
* Diverses modifications mineures ont toutefois ete apportees pour
* simplifier un peu le code , notamment dans la facon d ’ initialiser
* les matrices , et ce dans le but de verifier ( avec des assertions )
* que le resultat final est bien celui attendu . Le style des
* declarations a aussi ete quelque peu modifie , notamment en
* utilisant le plus possible des declarations locales ( le plus pres
* du point d ’ utilisation ). Finalement , les echanges de blocs se font
* par l ’ intermediaire de Bcast a des sous - groupes ( memes lignes ou
* meme colonnnes ) plutot que par des envois point -a - point .
*
* Argument du programme :
* - Facteur multiplicatif pour taille de la matrice
*
* Note : bien que l ’ approche puisse traiter des matrices
* rectangulaires ( A : N X P , B : P X M , et donc C : N X M ) , pour
* simplifier l ’ utilisation du programme , un seul argument est
* specifie sur la ligne de commande , donc on traite
* effectivement des matrices carrees . De plus , pour assurer le
* respect de la condition que la taille soit divisible par la
* racine carre du nombre de processeurs , l ’ argument N ne
* determine pas la taille de facon exacte mais un facteur
* multiplicatif d ’ un nombre predefini produit des premiers
* entiers 2 , 3 , etc .
*/

# include < mpi .h >


# include < stdio .h >
# include < string .h >
# include < stdlib .h >
# include < math .h >
Produit parallèle de matrices 697

# include < assert .h >

// Pour debogger et generer une trace .

int _DEBUG = 0;
# define DEBUG ( x ) if ( _DEBUG ) x

//
Produit parallèle de matrices 698

// / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
// Signature des operations auxiliaires sur les matrices .
// / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /

/*
* Les operations qui suivent sur les matrices sont liees a la
* representation interne utilisee , a savoir , une matrice est
* representee par un vecteur ( lineaire ) -- donc il faut " jouer " de
* facon explicite avec l ’ adresse de base , les indices , le stride ,
* etc .
*
*/

// Initialisation d ’ une matrice avec une valeur v partout ( pour simplifier te


void initialize ( double *A , int dimNb , int dimMb , int dimM , double v );

// Operation pour acceder a l ’ element A [i , j ] , et a partir de l ’ adresse de


// base de la matrice et du stride .
double get ( double *A , int i , int j , int stride );

// Operation qui fait le produit des blocs indiques de A et B et


// additionne le resultat au bloc correspondant de C .
void matmul_add ( double *A , double *B , double *C ,
int dimNb , int dimPb , int dimMb ,
int strA , int strB , int strC );

//
Produit parallèle de matrices 699

// / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
// Operations pour debogage et test .
// / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /

// Constantes pour definir le contenu des matrices A et B et assurer


// que l ’ on peut tester facilement , avec des assertions , que le
// produit resultant est correct .
# define V1 2.0
# define V2 3.0

// Verification du contenu d ’ une matrice -- pour tests .


void verifier ( double *A , int dimNb , int dimPb , int stride , double res );

// Nom d ’ un bloc du resultat -- pour debogage .


char * mkName ( char * nom , int myID_i , int myID_j , int myID );

//
Produit parallèle de matrices 700

// / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
// Fonction auxiliaire pour distribution des blocs de donnees .
// / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /

void bcastBloc ( double * bloc , double * buffer , int nbElements ,


int source , MPI_Comm communicator )
{
int myID ;
MPI_Comm_rank ( communicator , & myID );

if ( myID == source ) memcpy ( buffer , bloc , nbElements * sizeof ( double ));

MPI_Bcast ( buffer , nbElements , MPI_DOUBLE , source , communicator );


}

//
Produit parallèle de matrices 701

// / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
// Programme principal .
// / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /

int main ( int argc , char * argv [] )


{
// On initialise le programme MPI et on identifie les arguments .
int numProcs , myID ;
MPI_Init ( & argc , & argv );
MPI_Comm_size ( MPI_COMM_WORLD , & numProcs );
MPI_Comm_rank ( MPI_COMM_WORLD , & myID );

// Lecture de la taille des matrices ( carres ) a traiter . Pour


// simplifier le traitement par blocs , on s ’ arrange pour que cette
// taille soit presqu ’ assurement divisible par la racine carre du
// nombre de processeurs .
int N = 2 * 2 * 3 * 5 * 7 * atoi ( argv [1]);

// On definit la taille des matrices , des blocs , etc .


// Matrices carres pour simplifier , meme si le code traite des
// matrices rectangulaires .
int dimN = N ;
int dimP = N ;
int dimM = N ;

// On definit NB = Nombre de blocs selon une dimension donnee , donc


// NB X NB = numProcs .
int NB = ( int ) sqrt ( ( float ) numProcs );
assert ( N % NB == 0 && " Mauvaise valeur pour N ou NB : NB ne divise
assert ( NB * NB == numProcs && " Mauvais nombre de processeurs : NB * NB != N "

int dimNb = dimN / NB ;


int dimPb = dimP / NB ;
int dimMb = dimM / NB ;

//
Produit parallèle de matrices 702

// On alloue l ’ espace local pour les blocs .


double * A = malloc ( dimNb * dimPb * sizeof ( double ) );
double * B = malloc ( dimPb * dimMb * sizeof ( double ) );
double * C = malloc ( dimNb * dimMb * sizeof ( double ) );
double * Abuffer = malloc ( dimNb * dimPb * sizeof ( double ) );
double * Bbuffer = malloc ( dimPb * dimMb * sizeof ( double ) );

// On initialise les matrices a traiter .


initialize ( A , dimNb , dimPb , dimPb , V1 );
initialize ( B , dimPb , dimMb , dimMb , V2 );

//
Produit parallèle de matrices 703

// Numeros de ligne et de colonne du processus local .


int myID_i = myID / NB ;
int myID_j = myID % NB ;

// On definit les sous - groupes de processus qui sont sur la meme


// ligne ou meme colonne .
MPI_Comm lignes , colonnes ;
MPI_Comm_split ( MPI_COMM_WORLD , myID_i , MPI_UNDEFINED , & lignes );
MPI_Comm_split ( MPI_COMM_WORLD , myID_j , MPI_UNDEFINED , & colonnes );

//
// On effectue le produit a l ’ aide des NB phases .
//
initialize ( C , dimNb , dimMb , dimMb , 0.0 );

for ( int kb = 0; kb < NB ; kb ++ ) {


// On effectue le transfert des donnees .
bcastBloc ( A , Abuffer , dimNb * dimPb , kb , lignes );
bcastBloc ( B , Bbuffer , dimPb * dimMb , kb , colonnes );

// On traite le bloc de donnees .


matmul_add ( Abuffer , Bbuffer , C ,
dimNb , dimPb , dimMb ,
dimPb , dimMb , dimMb );
}

//
Produit parallèle de matrices 704

// On verifie formellement que le resultat est le bon .


verifier ( C , dimNb , dimMb , dimMb , (( float ) N ) * V1 * V2 );

// On imprime le resultat on termine .


if ( myID == 0 ) {
printf ( " Resultat = OK pour N = % d \ n " , N );
}

MPI_Finalize ();
return ( 0 );
}

//
Produit parallèle de matrices 705

// / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
// Mise en oeuvre des diverses operations auxiliaires .
// / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /

//
// Operations pour manipulation des matrices par bloc .
//

void initialize ( double *A , int dimNb , int dimMb , int stride , double v )
{
// A contient l ’ adresse de base du bloc , donc pointe a la premiere ligne !
for ( int i = 0; i < dimNb ; i ++ ) {
double * pt = A ;
for ( int j = 0; j < dimMb ; j ++ ) {
* pt ++ = v ;
}
A += stride ; // Adresse de la prochaine ligne .
}
}

//
Produit parallèle de matrices 706

// Macro pour acceder a un element de la matrice .


# define get ( A , i , j , stride )\
(*(( A ) + ( i )*( stride ) + ( j )))

// Operation pour effectuer les produits et sommes requis pour l ’ etape .


void matmul_add ( double *A , double *B , double *C ,
int dimNb , int dimPb , int dimMb ,
int strA , int strB , int strC )
{
for ( int i = 0; i < dimNb ; i ++ ) {
double * pt = C ;
for ( int j = 0; j < dimMb ; j ++ ) {
double r = 0.0;
for ( int k = 0; k < dimPb ; k ++ ) {
r += get (A , i , k , strA ) * get (B , k , j , strB );
}
* pt ++ += r ;
}
C += strC ;
}
}
Produit parallèle de matrices 707

Quelques explications :

• numProcs = Nombre de processeurs spécifié à l’appel de mpirun — qui doit être un


carré parfait.

• Tel qu’indiqué plus haut, la programme effectue le produit C = A × B, avec A: dimN × dimP,
B: dimP × dimM et C: dimN × dimM.
Toutefois, en pratique, pour simplifier l’appel du programme, dimM = dimP = dimN
= N, où N est l’argument du programme.

• NB = Nombre de sous-blocs sur une ligne ou sur une colonne, donc NB×NB =
numProcs.

• dimKb, avec K∈{N,P,M} = nombre d’éléments de base (double) dans un bloc selon
la dimension dimK, donc dimKb = dimK / NB.

• matmul_add( A, B, C, ... ); : Fait le calcul matriciel C += A ∗ B, via manip-


ulation de blocs.

• get( A, i, j, stride ) : Obtient l’élément A[i,j] du bloc.

Performances
La figure 18.9 présente les accélérations obtenues sur turing.hpc.uqam.ca (30 processeurs
physiques) pour différentes tailles de matrices (N X N) et différents nombres de processus
(donc avec des processeurs virtuels lorsque le nombre de processus est supérieur à 30).
Produit parallèle de matrices 708

Figure 18.9: Accélérations absolues obtenues sur turing.hpc.uqam.ca (30 pro-


cesseurs physiques) avec la méthode de Mattson, Sanders et Massingil, et ce pour
différentes tailles de matrices et différents nombres de processus. Les tailles de
matrices ont été choisies pour que les blocs soient tous de taille égale, et ce pour
différents nombre de processus qui sont eux-mêmes des carrés (22 , 32 , 42 , 56 , 62 , 72 ).
Produit parallèle de matrices 709

18.4 Méthode de Fox [Pac97]


La méthode de Fox [Pac97] effectue les divers calculs de produits dans un ordre différent,
comme l’illustre la figure 18.10. Plus précisément, à l’étape k (k = 0, 1, . . . , n − 1), le
processus Pij effectue le calcul suivant de cij , où k̄ = (i + k) mod n :

cij += aik̄ · bk̄j

Comme précédemment, les cercles pointillés indiquent des données locales au processus
Pij : on remarque que les bij sont déjà tous au bon endroit pour effectuer la zéroième étape,
mais pas les aij . Avant d’effectuer les calculs des produits de la zéroième étape, les éléments
a appropriés doivent donc être transmis aux autres processus, tel que cela est illustré par
les flèches (normales) sur la figure 18.11.
En examinant la figure 18.10, on peut aussi remarquer qu’à l’étape 1, ces sont les valeurs
bij utilisées à l’étape 0 qui seront utilisées pour les produits calculés à cette nouvelle étape
— et ainsi de suite pour chacune des étapes subéquentes.
À une étape de l’algorithme de Fox, on va donc effectuer les opérations suivantes :

• Un des processus d’une rangée va transmettre aux autres processus de sa rangée sa


valeur de a.

• Chaque processus va effectuer le produit approprié.

• Dans chacune des colonnes, on va «décaler» (vers le haut, de façon cyclique) les
valeurs de b pour qu’elles soient disponibles à l’étape suivante de calcul.

Les diverses communications sont illustrées, pour la zéroième étape, à la figure 18.11,
où les flèches en pointillées représentent des «décalages» (vertical, au niveau des colonnes,
et cyclique). Signalons aussi qu’à l’étape subséquente, pour chacun des processus, ce sera le
b reçu qui sera à nouveau transmis pour poursuivre le décalage vertical, ce qui assurera que
tous les éléments b de la colonne auront ultimement été traités par chacun des processus
— voir figure 18.12.
Produit parallèle de matrices 710

Figure 18.10: Ordre de calcul des produits dans la méthode de Fox.


Produit parallèle de matrices 711

Figure 18.11: Communications lors de la zéroième étape dans la méthode de Fox.


Produit parallèle de matrices 712

Figure 18.12: Communications lors de l’étape 1 dans la méthode de Fox.


Produit parallèle de matrices 713

18.5 Méthode de Cannon


La première méthode n’utilise que des diffusions (dans les rangées et dans les colonnes).
La méthode de Fox utilise des diffusions dans les rangées, mais des décalages répétitifs dans
les colonnes. La méthode de Cannon [Qui03], quant à elle, n’utilise que des décalages,
tant au niveau des rangées que des colonnes.
Chapitre 19

MapReduce: Simplified Data


Processing on Large Clusters ⇒
transparents

Voir transparents à l’URL suivant :


http://www.labunix.uqam.ca/~tremblay/INF7235/Materiel/map-reduce.pdf

714
Chapitre 20

Les trois principaux paradigmes


d’interaction entre processus
communiquant par échanges de
messages

Andrews [And00, Chap. 1 et 9] indique qu’il existe trois (3) patrons de base pour les
interactions entre processus concurrents communiquant par l’intermédiaire de messages :

• Producteur/Consommateur.

• Client/Serveur.

• Pairs interagissants.

Ces patrons de base peuvent être combinés de différentes façons pour organiser les
interactions entre processus. Ainsi, au chapitre 9, Andrews présente sept (7) patrons, qu’il
nomme «paradigmes d’interactions» entre processus. Les trois (3) premiers paradigmes,
que nous allons brièvement présenter, s’appliquent plus particulièrement aux applications
parallèles, alors que les quatre (4) autres (que nous ne présenterons pas) sont orientés plus
spécifiquement vers les applications distribuées.
Les trois «paradigmes d’interaction entre processus» d’Andrews qui nous intéressent
sont les suivants [And00, Chap. 9] :

• manager/workers, which [may often] represent a distributed implemen-


tation of a bag of tasks;
• heartbeat algorithms, in which processes periodically exchange informa-
tion using a send then receive interaction;
• pipeline algorithms, in which information flows from one process to an-
other using a receive then send interaction;

715
Paradigmes d’interaction entre processus 716

20.1 Coordonnateur/travailleurs
On peut distinguer deux grandes classes de programmes parallèles avec coordonnateur et
travailleurs, qui se distinguent par la façon dont le travail est réparti entre les travailleurs :

• Approche statique : L’ensemble du travail à faire est décomposé en un nombre fixe


de tâches, qui sont réparties entre les travailleurs de façon statique, donc au début
du programme.
C’est l’approche utilisée dans de nombreux programmes SPMD (Single Program,
Multiple Data) lorsque le travail requis pour chaque tâche est sensiblement le même
d’une tâche à une autre et que ces tâches sont de forte granularité.
Par exemple, dans le cas de parallélisme de résultat, l’allocation des éléments du ré-
sultat à calculer par un processus (règle owner-computes) peut se faire, par exemple,
par tranche d’éléments contigus ou de façon cyclique.

• Approche dynamique : Le travail à faire est décomposé en de nombreuses tâches, qui


sont réparties entre les travailleurs en cours d’exécution. Dans un contexte d’échange
de messages, les tâches sont transmises aux travailleurs par un coordonnateur au fur
et à mesure qu’ils complètent les tâches qui leur ont précédemment été affectées.

Au niveau de la répartition de la charge (load balancing), l’approche statique est in-


téressante lorsque les tâches sont sensiblement de la même taille — au sens qu’elles pren-
nent sensiblement le même temps à être traitées. Par contre, si les tâches sont de tailles
inégales, une répartition dynamique peut conduire à une répartition plus uniforme de la
charge, donc à un meilleur temps global d’exécution.
Dans le cas de processus communiquant par échange de messages, l’approche dy-
namique conduit à une forme de sac de tâches distribué, aussi appelé work pool, processor
farm, work farm, et c’est le coordonnateur qui est responsable de gérer et distribuer les
tâches.
Paradigmes d’interaction entre processus 717

20.2 Algorithmes systoliques


Définition du dictionnaire (sur le Web) :
systole The rhythmic contraction of the heart, especially of the ventricles,
by which blood is driven through the aorta and pulmonary artery after each
dilation or diastole.
Extraits du manuel d’Andrews [And00, Chap. 9] :
The hearbeat paradigm is useful for many data parallel iterative applications.
In particular, it can be used when the data is divided among workers,
each is responsible for updating a particular part, and new data val-
ues depend on values held by workers or their immediate neighbors.
[. . . ]
We call this type of process interaction a heartbeat algorithm because the ac-
tions of each worker are like the beating of a heart: expand, sending infor-
mation out; contract, gathering new information; then process the
information and repeat.
[. . . ]
The send/receive interaction pattern in a heartbeat algorithm produces a
“fuzzy” barrier among the workers. [. . . ] Workers that are not neighbors can
get more than one iteration apart, but neighbors cannot. A true barrier is not
required because workers share data only with their neighbors.
Certains auteurs considèrent qu’un algorithme systolique est nécessairement associé à
des communications se faisant strictement avec les voisins immédiats. En ce sens, la «fron-
tière», la différence entre algorithme systolique et algorithme pipeline n’est pas toujours
bien définie et peut varier selon les auteurs.

20.3 Algorithmes pipelines


Extraits du manuel d’Andrews [And00, Chap. 9] :
Recall that a filter process receives data from an input port, processes it, and
sends result to an output port. A pipeline is a linear collection of filter
processes.
Une différence entre un algorithme systolique et un algorithme pipeline est générale-
ment la suivante :
• Dans un algorithme systolique, le traitement des données à une étape se fait
généralement après que les données locales aient été transmises aux voisins et que
les nouvelles données aient été reçues des voisins. On a donc généralement une
structure qui ressemble à la suivante :
Paradigmes d’interaction entre processus 718

REPETER
Envoyer les valeurs appropriées aux processus <<voisins>>
Recevoir les valeurs des processus <<voisins>>
Effectuer le traitement (en fonction des nouvelles valeurs)
JUSQUE condition pour terminer satisfaite

• Un algorithme pipeline contient lui aussi des réceptions et envois. Par contre,
les valeurs envoyées à une étape résultent généralement du traitement des données
reçues à cette même étape. La structure générale est donc habituellement la suivante,
plus précisément ici dans le cas d’un pipeline linéaire :

BOUCLER
Recevoir les valeurs du processus voisin <<précédent>>
SORTIR QUAND fin de flux rencontrée (EOS)
Effectuer le traitement
Transmettre les valeurs au voisin <<suivant>>
FIN

Les algorithmes pipelines sont souvent associés à une décomposition fonctionnelle


— chaque processus exécute une tâche d’un type différent, conduisant donc à du
parallélisme de spécialistes [CG89] : voir notes de cours «Chap. 1 : Les paradigmes
de base de la programmation concurrente», annexe C.
Paradigmes d’interaction entre processus 719

20.4 Synthèse des différents exemples vus en cours


Programme Coord./Trav. Systolique Pipeline
Multiplication de matrices
(MPD, Chap. 2.7)
Évaluation série de poly-
nomes (MPD, Chap.2.B.4)
Tri fusion
(MPD, Chap. 10)
Calcul de min/max
(MPD, Chap. 10)
Calcul de factoriel
(MPD, Exercice, série 4)
Diffusion de la chaleur
(MPI, Chap. 15)
Intégration numérique
(MPI, Chap.12.1)
Produit de matrices
(MPI, Chap. 16)
Recheche des mots-clés
(MPI, Labo MPI)
Partie VII

Annexes

720
Appendix A

La stratégie «diviser-pour-régner»
pour la conception d’algorithmes
récursifs

A.1 Diviser-pour-régner
– Diviser-pour-régner = approche descendante à la résolution d’un problème :
• On décompose le problème à résoudre en sous-problèmes plus simples.
• On trouve la solution des sous-problèmes.
• On combine les solutions des sous-problèmes pour obtenir la solution du problème
initial.
Cette approche conduit, de façon naturelle (mais pas obligatoirement), à un algorithme
récursif :
• Question = comment obtenir la solution des sous-problèmes?
• Réponse = en appliquant la même approche diviser-pour-régner descendante aux
sous-problèmes, et ce jusqu’à ce que le problème à résoudre soit trivial.

Note importante : pour que l’approche diviser-pour-régner puisse conduire à un algorithme


récursif, il faut que les sous-problèmes résultants soient similaires au problème initial.
Si ce n’est pas le cas, on peut quand même considérer qu’on utilise une approche diviser-
pour-régner, mais sans récursivité. (Voir section A.1.6).

A.1.1 Fouille binaire (dichotomique)


– Algorithme de la fouille binaire (recherche dichotomique) en notation MPD : Algo-
rithme A.1.

721
Stratégie diviser-pour-régner 722

procedure trouverPosition ( int A [*] , int n , int v )


returns int pos
# PRECONDITION
# SOME ( k >= 0 :: n = 2^ k ) ,
# ALL ( 1 <= i < n :: A [ i ] <= A [ i +1] )
# POSTCONDITION
# SOME ( 1 <= i <= n :: A [ i ] = v )
# = > (1 <= pos <= n ) & A [ pos ] = v ,
# ALL ( 1 <= i <= n :: A [ i ] ~= v )
# = > pos = 0
{
pos = trouverPosRec ( A , 1 , n , v )
}

procedure trouverPosRec ( ref int A [*] ,


int inf , int sup , int v )
returns int pos
{
if ( inf == sup ) {
pos = ( A [ inf ] == v ) ? inf : 0
} else {
int m = ( inf + sup ) / 2

if ( v <= A [ m ]) {
pos = trouverPosRec (A , inf , m , v )
} else {
pos = trouverPosRec (A , m +1 , sup , v )
}
}
}

Algorithme A.1: Fouille binaire sur un tableau ordonné d’éléments.


Stratégie diviser-pour-régner 723

– Analyse de l’algorithme :

• Opération barométrique : comparaison de l’élément v avec un élément du tableau


A.
• Taille du problème : n, le nombre d’éléments du tableau.
• Hypothèse simplificatrice : n = 2k , pour un certain k ≥ 0.
• Type d’analyse : pire cas.
• Solution informelle :
Niveau de Taille du prob- Nombre d’opérations
l’appel lème barométriques à ce
niveau
1 n 1
2 n/2 1
3 n/4 1
... ... 1
i n/2i−1 1
... ... 1
k n/2k−1 1
k+1 n/2k 1
Donc, complexité Θ(lg n).

A.1.2 Tri par fusion (mergesort)


– Objectif = trier un tableau d’éléments
– Idée générale du tri par fusion :

• On divise le tableau (de taille n) en deux-sous tableaux (de taille n/2).


• On trie (récursivement) les deux sous-tableaux.
• On fusionne (merge) les sous-tableaux triés.

– Algorithme du tri par fusion et de la procédure de fusion : Algorithme A.2.


Note : Dans une post-condition, une variable décorée d’un apostrophe, par ex., x’, dénote
la valeur de la variable avant l’exécution de la procédure, c’est-à-dire, au moment de
l’appel.
– Analyse de la complexité de la fusion (procédure fusionner) :

• Opération barométrique : affectation de A[-] vers C[-].


• Taille du problème : nombre total d’items à fusionner dans les deux sous-listes, donc
sup-inf+1.
Stratégie diviser-pour-régner 724

procedure trier ( ref int A [*] , int n )


# PRECONDITION
# SOME ( k >= 0 :: n = 2^ k )
# POSTCONDITION
# A est une permutation de A ’ ,
# ALL ( 1 <= i < n :: A [ i ] <= A [ i +1] )
{
trierRec ( A , 1 , n )
}

procedure trierRec ( ref int A [*] , int inf , int sup )


{
if ( inf == sup ) {
# Rien a faire : deja trie .
} else {
# Decomposition ... triviale !
int mid = ( inf + sup ) / 2

# Solution ( recursive ) des sous - problemes .


trierRec ( A , inf , mid )
trierRec ( A , mid +1 , sup )

# Combinaison des sous - solutions .


fusionner ( A , inf , mid , sup )
}
}
Stratégie diviser-pour-régner 725

procedure fusionner ( ref int A [*] ,


int inf , int mid , int sup )
# PRECONDITION
# inf <= mid <= sup ,
# ALL ( inf <= i < mid :: A [ i ] <= A [ i +1] ) ,
# ALL ( mid +1 <= i < sup :: A [ i ] <= A [ i +1] )
# POSTCONDITION
# A [ inf : sup ] est une permutation de A ’[ inf : sup ] ,
# ALL ( inf <= i < sup :: A [ i ] <= A [ i +1] )
{
int C [ inf : sup ] # Copie de travail pour fusion .
int i1 = inf , # Index pour la premiere moitie .
i2 = mid +1 ,# Index pour la deuxieme moitie .
i = inf # Index pour la copie .

# On selectionne le plus petit element


# des deux moities en le copiant dans C .
while ( i1 <= mid & i2 <= sup ) {
if ( A [ i1 ] < A [ i2 ]) {
C [ i ++] = A [ i1 ++]
} else {
C [ i ++] = A [ i2 ++]
}
}

# On transfere la partie non copiee dans C .


for [ k = i1 to mid ] { C [ i ] = A [ k ]; i += 1 }
for [ k = i2 to sup ] { C [ i ] = A [ k ]; i += 1 }

# On recopie le tableau C dans A ."


for [ i = inf to sup ] { A [ i ] = C [ i ] }
}

Algorithme A.2: Tri fusion.


Stratégie diviser-pour-régner 726

• Type d’analyse : pire cas.


Soit n le nombre d’éléments qu’on veut fusionner, c’est-à-dire, n = sup-inf+1. La
complexité de la procédure fusionner sera donc linéaire :

T (n) = n ∈ Θ(n)

– Analyse de la complexité du tri par fusion :

• Opération barométrique : opérations barométriques effectuées dans la procédure de


fusion, dans la mesure où c’est là que le véritable travail est effectué.
• Taille du problème : n, le nombre d’éléments dans le tableau à trier.
• Type d’analyse : pire cas.
• Solution informelle :
Niveau Taille du Nombre d’opérations Nombre de Nombre to-
de problème par sous-problème (di- noeuds (de tal d’opérations
l’appel vision et combinaison) sous-pro- barométriques (pour
blèmes) à l’ensemble du niveau)
ce niveau
1 n n/2 + n/2 = n 1 1 × (n) = n
2 n/2 n/4 + n/4 = n/2 2 2 × (n/2) = n
3 n/4 n/8 + n/8 = n/4 22 22 × (n/4) = n
... ... ... ... ...
i n/2i−1 n/2i−1 2i−1 2i−1 × (n/2i−1 ) = n
... ... ... ... ...
k n/2k−1 n/2k−1 − 1 2k−1 2k−1 × (n/2k−1 ) = n
k+1 n/2k 0 2k−1 2k × 0 = 0
Nombre total d’opérations :

k
X
n = n × k = n lg n
i=1

Donc, l’algorithme est Θ(n lg n).

Note : il est important de pouvoir faire, lorsque nécessaire, une telle analyse informelle
(basée sur la structure de l’arbre, le nombre de noeuds, le travail effectué dans chaque
noeud, etc.) de la complexité (même non exacte) . . . parce que cela nous permet souvent
de mieux comprendre le fonctionnement de l’algorithme (récursivité, nombre de niveaux
d’appel, nombre de sous-tâches générées, etc.).
Stratégie diviser-pour-régner 727

A.1.3 L’approche diviser-pour-régner en général


Les trois étapes de l’approche diviser-pour-régner :

1. Diviser un problème en sous-problèmes plus simples.

2. Résoudre les sous-problèmes.

3. Combiner les solutions des sous-problèmes pour obtenir la solution au problème de


départ.

Dans certains cas, la majeure partie du travail se fait au niveau de la division en sous-
problèmes (par ex., quicksort) alors que pour d’autres cas la majeure partie du travail se
fait au niveau de la combinaison des solutions aux sous-problèmes (par ex., tri par fusion).
– De façon générique, la stratégie diviser-pour-régner peut être exprimée de la façon in-
formelle (pseudocode) telle que présentée à l’algorithme A.3.

PROCEDURE resoudreDiviserPourRegner( p: Probleme ):


Solution
SI p est un problème simple ALORS
sol <- on résout le problème simple p
SINON
(p1 , ..., pk ) <- on décompose le problème p
en k sous-problèmes
POUR i <- 1 A k FAIRE
soli <- resoudreDiviserPourRegner( pi )
FIN
sol <- on combine les solutions sol1 , ..., solk
des sous-problèmes
FIN
RETOURNER( sol )
FIN

Algorithme A.3: Diviser-pour-régner générique (décomposition en k sous-


problèmes).

A.1.4 Tri rapide (Quicksort)


– Algorithme célèbre développé par C.A.R. Hoare (1962).
– Son comportement dans le pire cas n’est pas très bon, mais en moyenne, en pratique sur
des séquences typiques et en choisissant bien le pivot, son comportement est intéressant.
Stratégie diviser-pour-régner 728

procedure partitionner ( ref int A [*] , int inf , int sup ,


res int posPivot )
# PRECONDITION
# inf < sup
# POSTCONDITION
# A est une permutation de A ’ ,
# inf <= posPivot <= sup ,
# ALL ( inf <= i <= posPivot :: A [ i ] <= A [ posPivot ] ) ,
# ALL ( posPivot < i <= sup :: A [ posPivot ] < A [ i ] )
{
posPivot = inf
int pivot = A [ posPivot ]
for [ i = inf +1 to sup ] {
if ( A [ i ] <= pivot ) {
posPivot += 1
A [ i ] :=: A [ posPivot ] # Echange les deux elements .
}
}

# On deplace le pivot a sa bonne position .


A [ posPivot ] :=: A [ inf ] # Echange les deux elements .
}
Stratégie diviser-pour-régner 729

procedure trierRec ( ref int A [*] , int inf , int sup )


{
if ( inf >= sup ) {
# Tableau vide ou de taille 1: rien a faire
} else {
int posPivot
# Decomposition en deux sous - problemes .
partitionner ( A , inf , sup , posPivot )

# Solution ( recursive ) des deux sous - problemes .


trierRec ( A , inf , posPivot -1 )
trierRec ( A , posPivot +1 , sup )

# Combinaison des sous - solutions .


# Rien a faire !
}
}

procedure trier ( ref int A [*] , int n )


{
trierRec ( A , 1 , n )
}

Algorithme A.4: Tri quicksort.


Stratégie diviser-pour-régner 730

• Algorithme pour partitionnement (décomposition en deux sous-séquences dont les


éléments sont, respectivement, inférieurs ou égaux, et supérieurs) : Algorithme A.4,
procédure partitionner.
Complexité du partitionnement : T (n) = n − 1 = Θ(n).

• Algorithme pour tri rapide : Algorithme A.4.

• Analyse du pire cas pour tri rapide :


– Pire cas = le tableau est déjà trié. Dans ce cas, l’élément choisi comme pivot
est le plus petit élément et la partie gauche (premier appel à trierRec sur
l’intervalle inf à posPivot-1) est alors vide alors que la partie droite (appel
récursif sur l’intervalle posPivot+1 à sup) contient tous les éléments sauf le
plus petit, donc contient n − 1 éléments.
Donc, le pire cas sera Θ(n2 ) :

T (n) = T (n − 1) + n − 1
= [T (n − 2) + n − 2] + n − 1
= T (n − 2) + (n − 2) + (n − 1)
= T (n − 3) + (n − 3) + (n − 2) + (n − 1)
= ...
= T (n − i) + (n − i) + (n − i + 1) + . . . + (n − 2) + (n − 1)
= ...
= T (1) + 1 + 2 + . . . + (n − 2) + (n − 1)
= 0 + 1 + 2 + . . . + (n − 2) + (n − 1)
n−1
X
= i
i=0
n(n − 1)
=
2

– Malgré le résultat obtenu pour l’analyse du pire cas, le tri rapide est généralement
considéré comme un tri intéressant. De façon relativement rigoureuse, ceci peut être montré
en utilisant une analyse de la complexité moyenne (cf. le cours INF4100). Ceci peut aussi
être montré, de façon informelle, tel qu’illustré dans les paragraphes qui suivent.

Variante améliorée du tri rapide


Dans l’algorithme A.4, le choix du pivot dans la procédure partitionner est fait à l’aide
des instructions suivantes :
Stratégie diviser-pour-régner 731

posPivot = inf
int pivot = A [ posPivot ]
La boucle for qui suit immédiatement le choix du pivot peut alors débuter son exécu-
tion sous la condition que le pivot est initialement à la position inférieure du tableau.
Supposons maintenant que l’on dispose d’une fonction posMediane jouant un rôle
d’oracle et qui, en temps Θ(n), peut trouver la position de l’élément médiane de A.1
Remplaçons alors les deux instructions mentionnées plus haut par la suite d’instructions
suivantes :

# On identifie l’element mediane et on le selectionne comme pivot.


posPivot = posMediane( A, inf, sup )
int pivot = A[posPivot]
# On ramene l’element pivot au debut du tableau.
A[inf] :=: A[posPivot]
posPivot = inf

En supposant que les deux moitiés sont effectivement réparties de façon égale (grâce
au choix de la médiane comme pivot), on obtiendrait alors une décomposition équilibrée
(générant deux sous-problèmes de tailles presqu’identiques) semblable à celle du tri par
fusion.
La question qui se pose alors est de savoir s’il est possible de déterminer la médiane
en temps linéaire. En d’autres mots, est-il possible de réaliser, ou même simplement
approximer, le comportement de l’oracle calculant la médiane? La réponse à cette question
est positive :
1. En fait, il existe un algorithme qui permet effectivement de déterminer, en temps
linéaire, la médiane d’un groupe d’éléments.

2. Une autre façon d’obtenir une approximation de la médiane pourrait être d’utiliser
un algorithme de type Las Vegas, c’est-à-dire, un algorithme aléatoire (probabiliste).
Par exemple, on pourrait choisir, de façon aléatoire, un certain nombre fixe d’éléments
du tableau (par ex., cinq éléments choisis au hasard) et ensuite identifier, en temps
constant (puisqu’on a un nombre fixe d’éléments), la médiane parmi ces cinq élé-
ments. La valeur espérée serait alors une approximation de la médiane qui ferait
en sorte que, en moyenne, les deux sous-tableaux générés lors du partitionnement
seraient de tailles équivalentes.

Tri par fusion vs. tri rapide


Les algorithmes de tri par fusion et de tri rapide sont généralement présentés (avec la fouille
binaire) comme les archétypes de l’approche diviser-pour-régner. Ces deux algorithmes se
1
Rappelons que l’élément médiane est celui qui, dans une liste ordonnée des éléments, se trouve
au milieu, c’est-à-dire qu’il y a autant d’éléments qui lui sont inférieurs qu’il y en a qui lui sont
supérieurs.
Stratégie diviser-pour-régner 732

distinguent tout d’abord par leur complexité dans le pire cas : O(n lg n) par opposition à
O(n2 ). Ils se distinguent aussi en fonction de l’espace requis pour exécuter chacun d’eux :

• Tri par fusion : nécessite de l’espace supplémentaire (Θ(n)) où la séquence fusionnée


(C) pourra être créée — la fusion en place n’est pas possible.
• Tri rapide : peut se faire en place, donc ne demande aucun espace mémoire addi-
tionnel (sauf pour les variables locales).

Dans le contexte de la présentation de l’approche diviser-pour-régner, ces deux algo-


rithmes se distinguent plus particulièrement en termes des coûts associés aux différentes
composantes (“phases”) de l’approche diviser-pour régner :

Algorithme (coût Coût de la décom- Coût de l’assem- Coût pour la Complexité totale
pour chaque ap- position en sous- blage des sous- partie non-
pel) problèmes solutions récursive
Tri par fusion O(1) O(n) O(n) O(n lg n)

Tri rapide pire O(n) O(1) O(n) O(n2 )


cas (avec mauvais
pivot)
Tri rapide (avec O(n) O(1) O(n) O(n lg n)
médiane comme
pivot)

En d’autres mots, le gros du travail dans le cas de tri par fusion se fait au moment de
la combinaison des solutions (fusion des parties déjà triées), alors que la décomposition en
sous-problèmes semblables au problème initial est trivial (on divise en deux parties de taille
équivalente, indépendamment du contenu, en utilisant simplement l’index milieu). Par
contre, dans le tri rapide, c’est la décomposition en sous-problèmes qui est coûteuse (par-
titionnement en deux parties comportant les éléments inférieurs vs. supérieurs au pivot)
alors que la combinaison des solutions est triviale (les deux parties sont déjà triées et dans
le bon ordre).

A.1.5 Comment déterminer à quel moment cesser les appels


récursifs
– Dans la mise en oeuvre directe de la stratégie diviser-pour-régner avec récursivité, on
cesse la récursion (la décomposition en sous-problèmes) lorsque le problème à résoudre est
vraiment trivial, c’est-à-dire qu’il ne peut plus du tout être décomposé (typiquement, de
taille 1).
Stratégie diviser-pour-régner 733

– Dans l’abstrait, les surcoûts (overhead ) associés à la gestion des appels récursifs (par
ex., allocation et copie des arguments sur la pile) peuvent être ignorés. En pratique, ces
surcoûts peuvent devenir significatifs et il peut devenir plus efficace, à partir d’une certaine
taille de problème, d’utiliser une approche asymptotiquement moins efficace, mais avec un
coefficient plus faible au niveau des surcoûts. La stratégie diviser-pour-régner avec seuil
(avec coupure) devient alors, en gros, celle illustrée à l’algorithme A.5 (en supposant une
décomposition dichotomique, c’est-à-dire en deux sous-problèmes) :

Solution resoudre_dpr( Probleme p )


{
if taille(p) <= TAILLE_MIN {
// Solution non-récursive
return resoudre_algo_simple(p)
} else {
Probleme p1, p2
p1, p2 = decomposer(p)
// Appels récursifs
Solution s1 = resoudre_dpr(p1)
Solution s2 = resoudre_dpr(p2)
return combiner(p, s1, s2)
}
}

Algorithme A.5: Diviser-pour-régner dichotomique (deux sous-problèmes) avec


seuil pour terminer la récursion.

Le choix du seuil (threshold ) à partir duquel la récursivité doit se terminer dépend de


nombreux facteurs :

• Mise en oeuvre exact de l’algorithme.


• Langage et compilateur utilisés.
• Machine et système d’exploitation sur lesquels s’exécute le programme.
• Données sur lesquelles le programme s’exécute.

En d’autres mots, l’analyse théorique de l’algorithme ne suffit plus. On doit plutôt


utiliser diverses techniques et outils pratiques et empiriques, par exemple, on pourrait
exécuter le programme avec différentes données et mesurer son temps d’exécution, ou bien
utiliser l’outil gprof (sur Unix/Linux) pour déterminer les fonctions et procédures les plus
fréquemment appelées et déterminer où sont les points chauds, etc.
Stratégie diviser-pour-régner 734

A.1.6 Quand ne pas utiliser l’approche diviser-pour-régner


(avec récursivité)
Pour que l’approche diviser-pour-régner avec récursivité conduise à une solution efficace,
il ne faut pas que l’une ou l’autre des conditions suivantes survienne :

1. Un problème de taille n est décomposé en deux ou plusieurs sous-problèmes eux-


mêmes de taille presque n (par ex., n − 1).

2. Un problème de taille n est décomposé en n sous-problèmes de taille n/c (pour une


constante c ≥ 2).

Dans l’un ou l’autre de ces cas, le temps d’exécution résultant est alors de complexité
exponentielle ou factorielle, ce qui rend donc l’algorithme tout à fait inefficace et inutilis-
able, sauf pour de (très) petites valeurs de n. (Pour s’en convaincre, il suffit d’identifier et
d’analyser les équations de récurrence associées à des telles décompositions récursives.)

Et peut-on utiliser diviser-pour-régner. . . sans récursivité?


L’approche diviser-pour-régner avec récursivité ne peut être utilisée que si les sous-problèmes
qui sont générés lors de la décomposition du problème initial sont du même type que le
problème initial (par ex., le tri d’un tableau se fait en triant ses sous-tableaux). Toutefois,
il existe de nombreux problèmes où on peut considérer qu’une approche diviser-pour-régner
est utilisée, et ce même si aucune récursivité n’est nécessaire ou utile.
Par exemple, supposons qu’on ait le problème suivant :

• Entrée : Un fichier commentaires.txt contient des lignes de la forme suivante, où


les différents noms peuvent ou non être distincts, et où les différentes lignes sont
ordonnées selon la date (croissante) :

Date Nom Commentaire


Date Nom Commentaire
Date Nom Commentaire
...

• Sortie : On désire obtenir le nombre de noms distincts qui sont présents dans le
fichier commentaires.txt.
Stratégie diviser-pour-régner 735

On peut dire que l’algorithme non récursif suivant utilise une stratégie diviser-pour-
régner, puisqu’il y a décomposition du problème initial en sous-problèmes plus simples,
lesquels sous-problèmes sont résolus de façon indépendante :

DEBUT
noms <- obtenir la liste des noms contenus dans le fichier commentaires.txt
noms_tries_et_uniques <- trier (de façon unique) les noms
nb <- nombre d’éléments dans noms_tries_et_uniques
RETOURNER( nb )
FIN

En fait, on peut dire que la stratégie diviser-pour-régner, sans récursivité, est la base
même de la décomposition fonctionnelle.
Finalement, il est intéressant de noter que cet algorithme peut être mis en oeuvre de
façon très simple sur une machine Unix/Linux, et ce au niveau même du shell :

awk ’{print $2;}’ < commentaires.txt | sort -u | wc -l

On reviendra ultérieurement sur des exemples de ce style de décomposition lors de


l’études des stratégies de base de programmation parallèle (modèle producteurs-consommateurs
avec canaux de communication).
Stratégie diviser-pour-régner 736

A.2 Diviser-pour-régner générique. . . dans le con-


texte de la programmation fonctionnelle
– Un langage de programmation purement fonctionnel — on dit aussi langage applicatif —
est un langage basé uniquement sur l’utilisation de valeurs et de fonctions (au sens math-
ématique du terme, c’est-à-dire sans effet de bord), donc basé strictement sur l’évaluation
d’expressions.
Exemples de tels langages : SML, Miranda, Haskell, Id/pH, (sous-ensemble de) Lisp/Scheme.
– Forme générale d’un programme fonctionnel = série de déclarations (constantes et fonc-
tions), plus une expression à évaluer :
ident_1 = ...
ident_2 = ...
...
ident_k = ...
expression à évaluer

Note : une fonction, au sens mathématique du terme, n’est qu’une forme spéciale de
constante!
– Dans un langage fonctionnel, les fonctions sont des valeurs manipulables comme toutes
les autres (“citoyens de première classe”). Il est donc possible, et naturel, de transmettre
des arguments à une fonction qui sont eux-mêmes des fonctions, de retourner un résultat
qui est une fonction, de conserver une fonction dans une structure de données, etc.
– La programmation fonctionnelle est intéressante à cause de son style déclaratif, très près
de ce qu’on écrirait en mathématiques. Il est donc plus facile de raisonner à propos d’un
programme, c’est-à-dire, de déterminer les propriétés satisfaites par le programme) : on
peut utilier le raisonnement par substitution (raisonnement équationnel ): “equals can be
replaced by equals”, comme en mathématique.
– La plupart des langages fonctionnels modernes utlisent les séquences (listes) comme
structures de données de base. Les algorithmes s’expriment donc souvent en termes de
manipulations de listes.
– De façon générique, la stratégie diviser-pour-régner peut être exprimée de la façon in-
formelle (pseudocode) présentée à l’algorithme A.3 (p. 727).
– Dans un langage fonctionnel, l’utilisation de la stratégie diviser-pour-régner peut être
exprimée de façon explicite et générique (donc favorisant la réutilisation) en définissant un
groupe de fonctions appropriées, tel que cela est illustré dans l’extrait de code Haskell 1.
Quelques explications concernant cette fonction :
• La fonction reçoit cinq arguments, les quatre premiers étant eux-mêmes des fonc-
tions — les commentaires (partie à droite de “–”) indiquent le type de l’argument
correspondant :
Stratégie diviser-pour-régner 737

diviserPourRegner
estSimple -- (Probleme -> Bool) ->
resoudreProblemeSimple -- (Probleme -> Solution) ->
decomposerProbleme -- (Probleme -> [Probleme]) ->
combinerSolutions -- (Probleme -> [Solution] -> Solution) ->
probleme -- Probleme ->
-- Solution
= resoudreProbleme probleme
where
resoudreProbleme probleme
| estSimple probleme = resoudreProblemeSimple probleme
| otherwise = combinerSolutions probleme sousSolutions
where sousSolutions = map resoudreProbleme sousProblemes
sousProblemes = decomposerProbleme probleme

Code Haskell 1: Diviser-pour-régner générique en Haskell.

– estSimple : fonction qui détermine si un Probleme est simple ou non (type


Bool).
– resoudreProblemeSimple : fonction qui reçoit un Probleme simple et qui
produit la Solution appropriée.
– decomposerProbleme : fonction qui reçoit un Probleme et qui retourne une
séquence (une liste) de sous-problèmes associés (les symboles “[X]” dénotent
un type séquence dont les éléments sont de type X).
– combinerSolutions : fonction qui reçoit en argument le Probleme initial, une
séquence de Solutions aux sous-problèmes, et qui produit la Solution globale.

• Le corps de la fonction diviserPourRegner consiste en une définition (locale) de


la fonction resoudreProbleme combinée avec un appel à cette fonction. C’est cette
fonction qui réalise, en Haskell, la logique décrite à l’algorithme A.3.
Notons que la fonction map est une fonction pré-définie du langage qui reçoit en
arguments une fonction et une liste et qui retourne une liste résultant de l’application
de la fonction à chacun des éléments de la liste. Quelques exemples :

map fois2 [10, 20, 30] => [20, 40, 60]


map plus1 [10, 20, 30] => [11, 21, 31]
map (plus 10) [1, 2, 3] = [11, 12, 13]
map additionner [(10, 20), (11, 21), (3, 20)] = [30, 32, 23]

fois2 x = 2 * x
plus x y = x + y
plus1 x = plus 1 x -- Autre facon: plus1 = plus 1
Stratégie diviser-pour-régner 738

additionner (x, y) = x+y

Dans l’extrait de code Haskell 1, la fonction map applique donc la fonction resoudreProbleme
à chacun des sous-problèmes de la liste sousProblemes et retourne une liste des
sousSolutions.

quicksort :: [Int] -> [Int]


quicksort l = diviserPourRegner estVide vide partitionner combiner l
where
estVide l = l == []
vide l = []
partitionner (x : xs) = [ filter ((<=) x) xs,
[x],
filter ((>) x) xs ]
combiner probleme solutions = fold (++) [] solutions

fibo n = diviserPourRegner estCasBase un partitionner combiner n


where
estCasBase n = n <= 1
un n = 1
partitionner n = [n-1, n-2]
combiner probleme solutions = fold (+) 0 solutions

Code Haskell 2: Quelques applications de la procédure diviserPourRegner


générique.

L’extrait de code Haskell 2 présente quelques exemples d’application de la procédure


générique diviserPourRegner :

• Une fonction de tri de type quicksort. On verra plus en détail cet algorithme à la
prochaine section.
• Une fonction pour calculer le nième nombre de Fibonnaci.
Stratégie diviser-pour-régner 739

A.3 Diviser-pour-régner générique. . . en Java


Le Programme Java A.1 présente une classe abstraite ProblemeDPR définissant un patron
générique (template pattern) pour la résolution d’un problème à l’aide de l’approche diviser-
pour-régner.
Le Programme Java A.2 présente une classe concrète Factoriel, sous-classe de la classe
ProblemeDPR, permettant de calculer la factoriel d’un nombre entier, et ce en instantiant
le patron générique (template pattern) de l’approche diviser-pour-régner.
Le Programme Java A.3–A.4 présente une classe concrète TriFusion, sous-classe de
la classe ProblemeDPR, permettant d’effectuer un tri fusion, et ce en instantiant le patron
générique (template pattern) de l’approche diviser-pour-régner.
Stratégie diviser-pour-régner 740

Programme Java A.1 Classe abstraite Java pour patron (template pattern)
diviser-par-régner générique.
//
// Une classe Java abstraite et generique pour representer des problemes
// a resoudre par une approche diviser-pour-regner.
//

abstract class ProblemeDPR<T> {


private T solution;

public T solution() { return(solution); }

public abstract boolean estSimple();

public abstract T resoudreSimple();

public abstract ProblemeDPR<T>[] decomposer();

public abstract T combiner( ProblemeDPR<T>[] s );

public void resoudre()


{
if ( estSimple() ) {
solution = resoudreSimple();
} else {
ProblemeDPR<T>[] problemes = decomposer();
for ( int i = 0; i < problemes.length; i++ ) {
problemes[i].resoudre();
}
solution = combiner( problemes );
}
}

}
Stratégie diviser-pour-régner 741

Programme Java A.2 Classe concrète Java pour calcul de factoriel.


//
// Calcul de factoriel vu comme une instance de la classe generique.
//
class Factoriel extends ProblemeDPR<Integer> {
int inf, sup;

Factoriel( int inf, int sup ) {


this.inf = inf;
this.sup = sup;
}

public boolean estSimple() {


return( inf == sup );
}

public Integer resoudreSimple() {


return( inf );
}

public Factoriel[] decomposer() {


int mid = (inf + sup)/2;
return( new Factoriel[] {
new Factoriel( inf, mid ),
new Factoriel( mid+1, sup ) } );
}

public Integer combiner( ProblemeDPR<Integer>[] s ) {


return( s[0].solution() * s[1].solution() );
}

public static void main(String[] args) {


if (args.length == 0) {
System.out.println( "Usage:" );
System.out.println( " java Factoriel n" );
System.exit(1);
}

Factoriel f
= new Factoriel( 1, Integer.parseInt(args[0]) );
f.resoudre();
System.out.println( f.solution() );
}
}
Stratégie diviser-pour-régner 742

Programme Java A.3 Classe concrète Java pour tri par fusion (première partie).
//
// Tri par fusion vu comme une instance de la classe generique.
//
class TriFusion extends ProblemeDPR<Integer[]> {
Integer[] elems;

TriFusion( Integer[] aTrier ) {


elems = aTrier;
}

public boolean estSimple() {


return( elems.length == 1 );
}

public Integer[] resoudreSimple() {


return( elems );
}

public TriFusion[] decomposer() {


assert elems.length % 2 == 0;

int mid = elems.length/2;

Integer[] gauche = new Integer[mid];


for ( int i = 0; i < mid; i++ ) {
gauche[i] = elems[i];
}
Integer[] droite = new Integer[mid];
for ( int i = 0; i < mid; i++ ) {
droite[i] = elems[i+mid];
}
return( new TriFusion[]{ new TriFusion(gauche), new TriFusion(droite) } );
}

public Integer[] combiner( Integer[] s ) {


Integer[] res = new Integer[solution().length + s.length];
int i1 = 0, i2 = 0, i = 0;
while ( i1 < solution().length && i2 < s.length ) {
if ( solution()[i1] < s[i2] ) {
res[i++] = solution()[i1++];
} else {
res[i++] = s[i2++];
}
}
for (int j = i1; j < solution().length; j++ ) {
res[i++] = solution()[j];
}
for (int j = i2; j < s.length; j++ ) {
res[i++] = s[j];
Stratégie diviser-pour-régner 743

Programme Java A.4 Classe concrète Java pour tri par fusion (deuxième partie).
public static void main(String[] args) {
if (args.length == 0) {
System.out.println( "Usage:" );
System.out.println( " java TriFusion n" );
System.exit(1);
}

int nbElems = Integer.parseInt(args[0]);


Integer[] vals = new Integer[nbElems];
for ( int i = 0; i < nbElems; i++ ) {
vals[i] = 100 - i;
}
TriFusion f = new TriFusion( vals );
f.resoudre();
for ( int i = 0; i < nbElems; i++ ) {
System.out.println( f.solution()[i] );
}
}
}
Appendix B

Un aperçu de git, un outil de


contrôle du code source

La première chose à faire pour développer du code


de façon professionnelle. . .
«You need to get the development infrastructure environment in order. That means adopt-
ing (or improving) the fundamental Starter Kit practices:

• Version control

• Unit testing

• Build automation

Version control needs to come before anything else. It’s the first bit of infrastructure
we set up on any project.»

«Practices of an Agile Developer—Working in the Real World », Subramaniam & Hunt,


2006.

744
Aperçu de git 745

B.1 Introduction : Qu’est-ce qu’un système de con-


trôle du code source?
A source code control system [is like] a giant UNDO key—a project-wide time
machine that can return you to those alcyon days of last week, when the code actually
compiled and ran.

«The Pragmatic Programmer», Hunt & Thomas, 2000.


Alcyon :

1. Calm and peaceful; tranquil.

2. Prosperous; golden: halcyon years.

Que permet de faire un système de contrôle du code source?


• Conserver tout le code source

• Prendre note de tous les changements effectués au code source et à sa documentation


⇒ permet de retourner à une version antérieure (qui, elle, fonctionnait!!)

• Identifier quels fichiers ont été modifiés

• Déterminer qui a modifié un bout de code

• Comparer des versions

• Fusionner des versions développées de façon concurrente

• Identifier et gérer les releases, les versions, les branches de développement

The Pragmatic Programmer’s Tip 23


Always Use Source Code Control

Always. Even if you are a single-person team on a one-week project. Even if


it’s a “throw-away” prototype. Even if the stuff you’re working on isn’t
source code. Make sure that everything is under source code control!
«The Pragmatic Programmer», Hunt & Thomas, 2000.
Aperçu de git 746

B.2 Qu’est-ce que git?


Ce document présente un bref aperçu de l’outil git, un système de contrôle du code source
— on dit aussi «système de contrôle des versions», donc le même genre d’outil que CVS et
svn (SubVersion), mais avec une approche quelque peu différente : distribuée plutôt que
centralisée.

Git is a distributed revision control system with an emphasis on speed,


data integrity, and support for distributed, non-linear workflows. Git was
initially designed and developed by Linus Torvalds for Linux kernel
development in 2005, and has since become one of the most widely adopted
version control systems for software development.
Source : https: // en. wikipedia. org/ wiki/ Git_ ( software)
Aperçu de git 747

B.3 Quelques caractéristiques de git


• Système sans verrouillage, permettant à plusieurs personnes de travailler «en même
temps» sur un même groupe de fichiers.

• Dépôt distribué plutôt que centralisé :


– CVS et svn : dépôt centralisé ⇒ tous les checkout, branch et commit entraînent
des communications réseaux pour accéder au dépôt centralisé.
– git : dépôt distribué ⇒ on peut faire des checkout, commit, branch, etc., sans
accéder au dépôt central — donc sans connexion Internet!

Ceci signifie qu’on peut préserver un historique détaillé de l’évolution locale d’un
projet sans interférer avec le code dans le dépôt central. Ce n’est que lorsqu’on
est certain que tout est correct qu’on peut alors faire un push pour transmettre les
changements appropriés au dépôt central.

• Avec git, on garde la trace non pas des noms de fichiers comme dans les autres
systèmes. . . mais bien des contenus.

In many ways you can just see git as a filesystem—it is content-addressable,


and it has a notion of versioning, but I really really designed it coming at
the problem from the viewpoint of a filesystem person (hey, kernels is what
I do), and I actually have absolutely zero interest in creating a traditional
SCM system.
L. Torvald

• Avec git, la création de tags et de branchs est peu coûteusen, donc il ne faut pas
s’en priver.

• Une commande, bisect, permet de parcourir automatiquement un historique de


commits pour trouver la version qui a introduit un bogue.
Aperçu de git 748

B.4 Concepts de base de git


• Répertoire courant = Espace de travail = collection de répertoires et de fichiers
— certains sous le «contrôle» de git, d’autres non.

• Index — appelé aussi staging area = Fichiers, nouveaux et modifiés, ayant été
ajoutés avec git add , donc sous le contrôle de git.
L’index regroupe les fichiers qui formeront le prochain commit.

• Commit = Ensemble de fichiers (et de répertoires) conservés dans le dépôt.


Un nouveau commit est créé, à partir du contenu de l’index, avec la commande
git commit .
À chaque commmit est associé diverses méta-données — identificateur (SHA check-
sum), auteur, date, description (message du commit), possiblement tag, etc.
Chaque commit possède un ou plusieurs parents — d’où la structure d’arborescence.

• Dépôt = Arborescence de commits.


La commande git checkout a pour effet de modifier le contenu du répertoire
courant pour qu’il contienne les fichiers du commit indiqué.

• Branche = Un commit spécifique associé à un point de développement indépendant.

• HEAD = La branche courante, qui deviendra le parent du prochain commit.


Aperçu de git 749

Figure B.1: Les trois niveaux d’un dépôt local git. Adapté de [Cha09].

La figure B.1 présente les trois niveaux d’un projet sour le contrôle de git, et les
principales actions qui permettent de «transférer» un fichier d’un niveau à un autre.
Aperçu de git 750

B.5 Les principales commandes


B.5.1 Configuration
$ git config --global --list
fatal: unable to read config file ’/home/inf5171/.gitconfig’:
No such file or directory

$ git config --global user.name ’Guy Tremblay’


$ git config --global user.email ’[email protected]
$ git config --global color.ui ’auto’

$ git config --global --list


user.name=Guy Tremblay
[email protected]
color.ui=auto

$ more ~/.gitconfig
[user]
name = Guy Tremblay
email = [email protected]
[color]
ui = auto

Important : Il ne faut jamais mettre dans le dépôt les fichiers de sauvegarde,


les fichiers temporaires, les fichiers qui sont générés par le compilateur, etc.:

$ cd ProjetJava

$ more .gitignore
*.class

# Fichiers d’archives
*.jar
*.war

# Fichiers de sauvegarde generes par emacs


*~
Aperçu de git 751

B.5.2 Création et initialisation d’un dépôt local


$ mkdir Projet1
$ cd Projet1

$ git init . # Notez le "." apres init


Initialized empty Git repository in /home/inf5171/Projet1/.git/

$ ls
$ ls -a
. .. .git
$ ls .git
branches config description HEAD hooks info objects refs
$

B.5.3 Ajout de fichiers


$ emacs factoriel.rb
...
$ emacs factoriel_spec.rb
...
$ ls
factoriel.rb factoriel_spec.rb

$ git status
# On branch master
#
# Initial commit
#
# Untracked files:
# (use "git add <file>..." to include in what will be committed)
#
# factoriel.rb
# factoriel_spec.rb
nothing added to commit but untracked files present (use "git add" to track)

$ git add factoriel.rb factoriel_spec.rb


$ git status
# On branch master
#
# Initial commit
#
# Changes to be committed:
Aperçu de git 752

# (use "git rm --cached <file>..." to unstage)


#
# new file: factoriel.rb
# new file: factoriel_spec.rb

B.5.4 Création d’un premier commit, puis d’un autre et ex-


amen de l’historique
$ git commit -m "Creation initiale du programme et fichier de test"
[master (root-commit) fd77def] Creation initiale du programme et fichier de test
2 files changed, 247 insertions(+)
create mode 100644 factoriel.rb
create mode 100644 factoriel_spec.rb

$ git status
# On branch master
nothing to commit, working directory clean

$ git log
commit fd77def4c0e84ef3855035bd6dd3d3645f6b3602
Author: Guy Tremblay <[email protected]>
Date: Mon Sep 7 09:04:41 2015 -0400

Creation initiale du programme et fichier de test

$ emacs factoriel.spec
...

$ git status
# On branch master
# Changes not staged for commit:
# (use "git add <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
#
# modified: factoriel.rb
#
no changes added to commit (use "git add" and/or "git commit -a")

$ git commit -a -m "Corrrectionn erreur cas recursif"


[master 31f0385] Corrrectionn erreur cas recursif
1 file changed, 1 insertion(+), 1 deletion(-)
Aperçu de git 753

$ git commit -a -m "Correction erreur cas de base" --amend


[master 0e6e655] Correction erreur cas de base
1 file changed, 1 insertion(+), 1 deletion(-)

$ git log
commit 0e6e655637e23c367270a29739811a8f3d86ab21
Author: Guy Tremblay <[email protected]>
Date: Mon Sep 7 09:04:58 2015 -0400

Correction erreur cas de base

commit fd77def4c0e84ef3855035bd6dd3d3645f6b3602
Author: Guy Tremblay <[email protected]>
Date: Mon Sep 7 09:04:41 2015 -0400

Creation initiale du programme et fichier de test

Figure B.2: La structure du dépôt après les deux premiers commits.


Aperçu de git 754

B.5.5 Examen des modifications et différences


Remarque : Dans ce qui suit, pour alléger la présentation, les caractères «#» en début
de ligne seront omis — comme cela est fait de toute façon pour certaines versions de git.

Différences répertoire courant vs. index : «git diff»


$ emacs factoriel.rb

$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working
directory)

modified: factoriel.rb

no changes added to commit (use "git add" and/or "git commit -a")
Aperçu de git 755

$ git diff
diff --git a/factoriel.rb b/factoriel.rb
index 5d63d76..7ba6e02 100644
--- a/factoriel.rb
+++ b/factoriel.rb
@@ -1,5 +1,6 @@
require ’pruby’

+# Programme pour calculer factoriel(n) de differentes facons.

def fact_seq_lineaire( n )
if n == 0

Différences index vs. dépôt — fichiers ajoutés mais non commités : «git
diff --cached»
$ git add factoriel.rb

$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)

modified: factoriel.rb

$ git diff

$ git diff --cached


diff --git a/factoriel.rb b/factoriel.rb
index 5d63d76..7ba6e02 100644
--- a/factoriel.rb
+++ b/factoriel.rb
@@ -1,5 +1,6 @@
require ’pruby’

+# Programme pour calculer factoriel(n) de differentes facons.

def fact_seq_lineaire( n )
if n == 0
Aperçu de git 756

Différences répertoire courant vs. dernier commit du dépôt : «git diff


HEAD»
$ emacs factoriel.rb
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)

modified: factoriel.rb

Changes not staged for commit:


(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working
directory)

modified: factoriel.rb

$ git diff
diff --git a/factoriel.rb b/factoriel.rb
index 7ba6e02..9b6e7e2 100644
--- a/factoriel.rb
+++ b/factoriel.rb
@@ -1,6 +1,9 @@
require ’pruby’

# Programme pour calculer factoriel(n) de differentes facons.


+#
+# Ecrit par Guy Tremblay, 2015.
+#

def fact_seq_lineaire( n )
if n == 0

$ git diff HEAD


diff --git a/factoriel.rb b/factoriel.rb
index 5d63d76..9b6e7e2 100644
--- a/factoriel.rb
+++ b/factoriel.rb
@@ -1,5 +1,9 @@
require ’pruby’

+# Programme pour calculer factoriel(n) de differentes facons.


Aperçu de git 757

+#
+# Ecrit par Guy Tremblay, 2015.
+#

def fact_seq_lineaire( n )
if n == 0

Description d’un commit


$ git commit -am "Ajout d’un commentaire explicatif"
[master f8a5114] Ajout d’un commentaire explicatif
1 file changed, 4 insertions(+)

$ git show f8a5114


commit f8a51148281eaf584fa6b4c8b5d39b66e4eebeee
Author: Guy Tremblay <[email protected]>
Date: Mon Sep 7 09:21:12 2015 -0400

Ajout d’un commentaire explicatif

diff --git a/factoriel.rb b/factoriel.rb


index 5d63d76..9b6e7e2 100644
--- a/factoriel.rb
+++ b/factoriel.rb
@@ -1,5 +1,9 @@
require ’pruby’

+# Programme pour calculer factoriel(n) de differentes facons.


+#
+# Ecrit par Guy Tremblay, 2015.
+#

def fact_seq_lineaire( n )
if n == 0

Différences entre deux commits


$ git log --abbrev-commit --pretty=oneline
f8a5114 Ajout d’un commentaire explicatif
0e6e655 Correction erreur cas de base
fd77def Creation initiale du programme et fichier de test

$ git diff f8a5114 0e6e655


diff --git a/factoriel.rb b/factoriel.rb
Aperçu de git 758

index 9b6e7e2..5d63d76 100644


--- a/factoriel.rb
+++ b/factoriel.rb
@@ -1,9 +1,5 @@
require ’pruby’

-# Programme pour calculer factoriel(n) de differentes facons.


-#
-# Ecrit par Guy Tremblay, 2015.
-#

def fact_seq_lineaire( n )
if n == 0

$ git diff 0e6e655 f8a5114


diff --git a/factoriel.rb b/factoriel.rb
index 5d63d76..9b6e7e2 100644
--- a/factoriel.rb
+++ b/factoriel.rb
@@ -1,5 +1,9 @@
require ’pruby’

+# Programme pour calculer factoriel(n) de differentes facons.


+#
+# Ecrit par Guy Tremblay, 2015.
+#

def fact_seq_lineaire( n )
if n == 0

Qui a fait quoi et quand?


$ git blame factoriel.rb
^fd77def (Guy Tremblay 2015-09-07 09:04:41 -0400 1) require ’pruby’
^fd77def (Guy Tremblay 2015-09-07 09:04:41 -0400 2)
f8a51148 (Guy Tremblay 2015-09-07 09:21:12 -0400 3) # Programme pour calculer factoriel(n) de diff
f8a51148 (Guy Tremblay 2015-09-07 09:21:12 -0400 4) #
f8a51148 (Guy Tremblay 2015-09-07 09:21:12 -0400 5) # Ecrit par Guy Tremblay, 2015.
f8a51148 (Guy Tremblay 2015-09-07 09:21:12 -0400 6) #
0e6e6556 (Guy Tremblay 2015-09-07 09:04:58 -0400 7)
^fd77def (Guy Tremblay 2015-09-07 09:04:41 -0400 8) def fact_seq_lineaire( n )
^fd77def (Guy Tremblay 2015-09-07 09:04:41 -0400 9) if n == 0
^fd77def (Guy Tremblay 2015-09-07 09:04:41 -0400 10) 1
^fd77def (Guy Tremblay 2015-09-07 09:04:41 -0400 11) else
^fd77def (Guy Tremblay 2015-09-07 09:04:41 -0400 12) n *
fact_seq_lineaire( n-1 )
^fd77def (Guy Tremblay 2015-09-07 09:04:41 -0400 13) end
^fd77def (Guy Tremblay 2015-09-07 09:04:41 -0400 14) end
Aperçu de git 759

.
.
.

B.5.6 Pour retourner en arrière


Pour laisser tomber des modifications pas encore ajoutées
$ git status
# On branch master
nothing to commit, working directory clean

$ emacs factoriel.rb

$ git status
# On branch master
# Changes not staged for commit:
# (use "git add <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
#
# modified: factoriel.rb
#
no changes added to commit (use "git add" and/or "git commit -a")

$ git checkout -- factoriel.rb

$ git status
# On branch master
nothing to commit, working directory clean

Pour laisser tomber des modifications ajoutées mais pas encore com-
mitées
$ git status
# On branch master
nothing to commit, working directory clean
$ emacs factoriel.rb
...
$ git add factoriel.rb

$ git status
# On branch master
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
Aperçu de git 760

#
# modified: factoriel.rb
#

$ git reset HEAD factoriel.rb


Unstaged changes after reset:
M factoriel.rb

$ git status
# On branch master
# Changes not staged for commit:
# (use "git add <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
#
# modified: factoriel.rb
#
no changes added to commit (use "git add" and/or "git commit -a")

Pour retourner dans le passé. . . de façon temporaire


On sauve l’état courant sur la pile (stash) puis on va voir un vieux commit :

$ git checkout fd77def


error: Your local changes to the following files would be overwritten
by checkout:
factoriel.rb
Please, commit your changes or stash them before you can switch branches.
Aborting

$ git stash
Saved working directory and index state WIP on master: 9cddf4b Ajout d’un commentaire explica
HEAD is now at 9cddf4b Ajout d’un commentaire explicatif
$ git stash list
stash@0: WIP on master: 9cddf4b Ajout d’un commentaire explicatif

$ git checkout fd77def


Note: checking out ’fd77def’.

You are in ’detached HEAD’ state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in
this state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you
Aperçu de git 761

may do so (now or later) by using -b with the checkout command again. Example:

git checkout -b new_branch_name

HEAD is now at fd77def... Creation initiale du programme et fichier de test

On retourne à la branche master et on dépile ce qu’on avait commencé à faire :

$ git status
# HEAD detached at fd77def

$ git log
commit fd77def4c0e84ef3855035bd6dd3d3645f6b3602
Author: Guy Tremblay <[email protected]>
Date: Sat Sep 5 09:41:33 2015 -0400

Creation initiale du programme et fichier de test

$ git checkout master


Previous HEAD position was fd77def... Creation initiale du programme et fichier de test
Switched to branch ’master’

$ git stash list


stash@0: WIP on master: 9cddf4b Ajout d’un commentaire explicatif

$ git stash pop


# On branch master
# Changes not staged for commit:
# (use "git add <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
#
# modified: factoriel.rb
#
no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@0 (2b12d77023f04b2f0898abaf189c85144f245980)
Aperçu de git 762

B.5.7 Pour créer une branche à partir de la branche princi-


pale (master)
Ce qu’est une branche et pourquoi en créer :

• Un dépôt git est une arborescence de commits. Une branche est simplement un
chemin alternatif de la branche principale de l’arborescence — du tronc (trunk ).

• Une branche permet de travailler sur le projet de façon indépendante, en «paral-


lèle» au projet principal, sans modifier la branche master.

• En git, on est toujours sur une branche :le projet principal est simplement la branche
nomméemaster.

• On peut créer une branche pour diverses raisons :


– Pour développer une nouvelle fonctionnalité.Lorsque le développement de la
fonctionnalité est complété, on peut alors intègrer la branche à la branche
principale.
– Pour corriger un bogue. Lorsque le bogue est corrigé, on intègre alors les
modifications appropriées à la branche principale en intégrant la branche.
– Pour faire des expérimentations, des essais. Lorsque ces essais sont terminés,
on peut alors laisser tomber la branche — i.e., la détruire sans l’intégrer.
– Pour permettre à des sous-équipes de travailler en parallèle sur des sous-projets.

• C’est l’opération merge qui permet d’intégrer — de fusionner — une autre branche
dans la branche courante :
– S’il n’y a aucun conflit — il n’y a pas une même ligne qui est modifiée dans
les deux branches — alors il n’y a rien de spécial à faire.
– S’il y a un conflit — une ou plusieurs lignes ont été modifiées dans les
deux branches — alors il faut résoudre le conflit, donc choisir les bonnes
modifications.

Remarque : Le principe est le même pour créer une branche à partir de n’importe quel
autre commit que HEAD.

Exemple de création d’une branche pour corriger un bogue et son inté-


gration sans conflit
$ git branch -a
* master

$ git checkout -b CORRECTION_BOGUE_CAS_BASE


Switched to a new branch ’CORRECTION_BOGUE_CAS_BASE’
Aperçu de git 763

$ git branch -a
* CORRECTION_BOGUE_CAS_BASE
master

$ emacs factoriel.rb
$ git status
# On branch CORRECTION_BOGUE_CAS_BASE
# Changes not staged for commit:
# (use "git add <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
#
# modified: factoriel.rb
#
no changes added to commit (use "git add" and/or "git commit -a")

$ git commit -am "Modification du cas de base pour x = 0"


[CORRECTION_BOGUE_CAS_BASE 5b91373] Modification du cas de base pour x = 0

$ git checkout master


Switched to branch ’master’
(master) Projet1@linux $ git br -a

$ git status
# On branch master
nothing to commit, working directory clean

$ git merge CORRECTION_BOGUE_CAS_BASE


Updating f8a5114..6a47805
Fast-forward
factoriel.rb | 2 ++
1 file changed, 2 insertions(+)

$ git branch -d CORRECTION_BOGUE_CAS_BASE


Deleted branch CORRECTION_BOGUE_CAS_BASE (was 5b91373).

$ git branch -a
* master

$ git log --abbrev-commit --pretty=oneline


6a47805 Modification du cas de base pour x = 0
f8a5114 Ajout d’un commentaire explicatif
0e6e655 Correction erreur cas de base
fd77def Creation initiale du programme et fichier de test
Aperçu de git 764

B.5.8 Pour utiliser un dépôt distant


Jusqu’à présent, toutes les opérations présentées se faisaient sur un dépôt local — donc
toute l’information était conservée dans le sous-répertoire .git local, sans connexion à
distance.
On peut évidemment travailler sur des dépôts distants — pour collaborer avec d’autres
développeurs ou simplement pour avoir une copie externe.
Brièvement, les principales commandes sont les suivantes :

• Pour obtenir une copie d’un dépôt distant :

$ git clone <URL dépôt distant>

• Pour donner un nom local au dépot distant :

$ git remote add <dépôt distant> <URL dépôt distant>

• Pour transférer des modifications vers le dépôt distant :

$ git push <dépôt distant>

• Pour mettre à jour la copie locale du dépôt distant :

$ git pull <dépôt distant>

Note : Pour plus de détails, voir la documentation git. Notamment, on peut


configurer pour que la branche courante master soit vers le dépot distant (remote
tracking branch, ce qui évite d’avoir à spécifier explicitement le dépôt avec push et
pull.

B.6 Les trois niveaux d’un projet sour le contrôle


de git : Exemple
$ echo ’a’ > foo.txt
# Repertoire courant: foo.txt = [’a’]; untracked file

$ git add foo.txt


# Repertoire courant: foo.txt = [’a’]
# Index: foo.txt = [’a’]; changes to be commited, new file
Aperçu de git 765

$ echo ’bb’ >> foo.txt


# Repertoire courant: foo.txt = [’a’, ’bb’]; changed not staged, modified
# Index: foo.txt = [’a’]; changes to be commited, new file

$ git commit -m "Ajout de ligne a"


# Repertoire courant: foo.txt = [’a’, ’bb’]; changed not staged, modified
# Index:
# Depot@HEAD: foo.txt = [’a’]

$ git add foo.txt


# Repertoire courant: foo.txt = [’a’, ’bb’]
# Index: foo.txt = [’a’, ’bb’]; changes to be commited, modified
# Depot@HEAD: foo.txt = [’a’]

$ git commit -m "Ajout de ligne bb"


# Repertoire courant: foo.txt = [’a’, ’bb’]
# Index:
# Depot@HEAD: foo.txt = [’a’, ’bb’]
Aperçu de git 766

B.7 Stratégie d’utilisation pour un laboratoire ou


devoir simple
• Définissez vos paramètre d’identification :

$ git config --global user.email ’[email protected]


$ git config --global user.name ’Guy Tremblay’

• Obtenez une copie locale du code :

$ git clone http://www.labunix.uqam.ca/~tremblay/git/Labo.git

• Ensuite, répétez jusqu’à ce que le laboratoire ou devoir soit terminé :


– Faites des modifications aux fichiersqui vous ont été fournis.
– Si vous désirer ajouter un nouveau fichier, par ex., foo.c, vous devez l’ajouter
explicitement :
$ git add foo.c
– Lorsque vous atteignez une étape clé (milestone), par exemple, l’une des ver-
sions à développer est complétée et semble fonctionnelle, faites un commit :
$ git commit -am ’Explications du changement’

• Si vous faites alors git status, vous pourriez obtenir quelque chose comme suit
(projet en C) :

$ git status
# On branch master
# Your branch is ahead of ’origin/master’ by 2 commits.
#
# Untracked files:
# (use "git add <file>..." to include in what will be committed)
#
# foo.o
# a.out
nothing added to commit but untracked files present (use "git add" to track)

C’est tout à fait correct : les fichiers binaires et exécutables générés par le processus
de compilation et d’assemblage du programme n’ont pas à être mis sous le contrôle
du code source. De plus, ce que vous avez dans votre compte est une copie locale du
dépôt (origin/master), auquel vous ne pouvez évidemment pas accéder en mode
écriture!
Aperçu de git 767

• Pour voir les différents commits effectués, vous pouvez utiliser la commande suiv-
ante :

$ git log

• Pour donner un nom symbolique explicite à votre commit, par exemple VERSION_SEQUENTIELLE_OK,
vous pouvez utiliser la commande suivante :

$ git tag VERSION_SEQUENTIELLE_OK

Par la suite, pour revenir à ce commit :

$ git checkout VERSION_SEQUENTIELLE_OK


Aperçu de git 768

B.8 Comparaisons avec svn et CVS


Le tableau B.1 présente une brève comparaison entre les outils git, svn et CVS. . . si vous
connaissez l’un de ces deux outils.

Commande git Commande svn Commande CVS


git init svnadmin create cvs -d<repo> init
git clone svn checkout cvs -d<repo> co <module>
git pull svn update cvs update -dP
git add svn add cvs add
git add; git commit svn commit cvs commit
git status svn status cvs status
git checkout <branch> svn switch <branch> cvs co -r <branch>
git merge <branch> svn merge <branch> cvs update -j
git checkout <file> svn revert <file> cvs update -C

Tableau B.1: Comparaisons entre git, svn et CVS.


Appendix

Style de programmation et qualité


du code

1 Introduction
Ce document présente quelques rappels1 sur les caractéristiques d’un programme bien écrit
— un programme écrit dans un bon style de programmation. Il présente tout d’abord
quelques principes généraux. Il présente ensuite les notions d’abstraction procédurale et
de couplage. Finalement, il présente divers contre-exemples et exemples, exprimés pour
la plupart en Ruby, illustrant concrètement quelques-unes de ces mauvaises, ou bonnes,
caractéristiques.
1
INF3135 ?

769
Style et qualité du code 770

Figure .1: La seule bonne mesure de qualité!


Style et qualité du code 771

2 Quelques principes généraux

Figure .2: KISS!

Figure .3: DRY!


Style et qualité du code 772

2.1 Principe KISS et principes reliés


• Principe KISS2

– Keep It Simple, Stupid!


– Keep It Short and Simple!

• Rasoir d’Occam3 :

– «Les hypothèses les plus simples sont les plus vraisemblables».

• Maxime attribuée à A. Einstein4 :

«Everything should be made as simple as possible, but no simpler ».

• Maxime attribuée à A. de St-Exupéry5 :

«La perfection est atteinte non pas quand il n’y a plus rien à ajouter, mais
quand il n’y a plus rien à retirer.»

• Citation de C.A.R. Hoare (The Emperor’s Old Clothes, CACM, February 1981) :

«There are two ways of constructing a software design. One is to make


it so simple that there are obviously no deficiencies; the other is to
make it so complicated that there are no obvious deficiencies. The
first method is far more difficult.»

2.2 Principe DRY et principes reliés


• DRY = Don’t Repeat Yourself

• Formulation de A. Hunt & D. Thomas (The Pragmatic Programmer—From Jour-


neyman to Master , 2000) :

Every piece of knowledge must have a single, unambiguous, authoritative


representation within a system.

• Autre formulation équivalente = Once and Only Once6 :

Each and every declaration of behavior should appear OnceAndOnlyOnce.


2
http://en.wikipedia.org/wiki/KISS_principle
3
http://fr.wikipedia.org/wiki/Rasoir_d’Ockham
4
http://en.wikipedia.org/wiki/KISS_principle
5
http://www.drop-zone-city.com/article.php3?id_article=221
6
http://c2.com/cgi/wiki?OnceAndOnlyOnce
Style et qualité du code 773

2.3 Règles de E. Raymond


Quelques règles suggérées par E. Raymond (The Art of Unix Programming, 2004) :

• Rule of Modularity: Write simple parts connected by clean interface.

• Rule of Clarity: Clarity is better then cleverness.

• Rule of Simplicity: Design for simplicity; add complexity only where you must.

• Rule of Transparency: Design for visibility to make inspections and debugging easier.

• Rule of Economy: Programmer time is expensive; conserve it in preference to ma-


chine time.

• Rule of Optimization: Prototype before polishing. Get it working before you


optimize it.
Style et qualité du code 774

3 Abstraction procédurale : Caractéristiques d’une


bonne méthode
3.1 Pourquoi créer une méthode
Diverses raisons justifient la création d’une méthode :

• Pour réduire la complexité du code vu par le lecteur : le corps de la méthode permet


de dissimuler les détails.
• Pour introduire une abstraction (procédurale) qui permet, à l’aide d’un nom bien
choisi, de mieux documenter le rôle d’un segment de code (self-documenting code).
• Pour éviter de dupliquer du code.
• Pour améliorer la portabilité : les détails liées à un environnement ou une machine
peuvent être dissimulés dans la méthode.

3.2 Quand utiliser une procédure par opposition à une fonc-


tion
Plusieurs auteurs considèrent qu’une fonction, qu’on utilise pour retourner un résultat
utilisable dans une expression, ne devrait jamais avoir d’effets de bord, c’est-à-dire ne de-
vrait modifier ni ses arguments, ni des variables globales. Seules les procédures devraient
avoir des effets de bord.
Notamment, B. Meyer a introduit le principe de séparation des commandes et des
requêtes :7

[A] method should either be a command that performs an action, or a query


that returns data to the caller, but not both. In other words, asking a ques-
tion should not change the answer.

Remarque : Mais, comme dans toute règle, il y a des exceptions!

3.3 Comment nommer une méthode


Le nom d’une méthode devrait décrire ce que fait la méthode (quoi?) et non pas comment
elle le fait. Ce nom qui décrit ce que fait la méthode devrait être le plus complet possible.
Il ne faut pas hésiter à utiliser des identificateur comptant plusieurs mots — mais sans
toutefois abuser.
7
http://en.wikipedia.org/wiki/Command-query_separation
Style et qualité du code 775

Plus spécifiquement :

• Pour nommer une fonction, décrit la valeur retournéepar la fonction, en utilisant


un prédicat dans le cas d’une fonction retournant un booléen, par exemple : nom,
prochain_client, couleur_du_fond, date, sommet, vide?, entete?, etc.
À éviter en Ruby : get_nom, get_sommet, etc.
• Pour nommer une procédure, verbe actif (à l’infinitif) préférablement possiblement
suivi d’un complément d’objet lorsqu’approprié, par exemple : depiler, calculer_moyennes,
indiquer_perte, imprimer, etc.

3.4 Comment déclarer les paramètres d’une méthode


• Il est préférable de définir les paramètres dans un ordre logique et d’utiliser le même
ordre pour les méthodes semblables.
Notamment, l’ordre suivant est proposé par certains auteurs :

i) les paramètres associés à des entrées qui ne sont pas modifiées ;


ii) les paramètres associés à des entrées mais qui sont aussi modifiées ;
iii) les paramètres utilisés uniquement pour retourner un résultat.

Note : Ne pas oublier qu’une méthode Ruby retourne toujours un résultat, même
s’il peut être nil ou être ignoré ⇒
• La méthode doit utiliser tous les paramètres. Si un paramètre n’est pas du tout
utilisé, alors il devrait être supprimé de l’en-tête.
• Les paramètres utilisés pour retourner un code de statut ou un code d’erreur de-
vraient apparaître à la fin de la liste des paramètres.
• Sauf exception, une méthode ne devrait pas avoir plus de sept (7) paramètres.
Style et qualité du code 776

4 La notion de couplage
Le niveau de couplage entre deux unités de programme décrit la force des dépendances
qui existent entre ces unités. Plus le couplage est élevé, plus des changements dans une
unité risquent d’avoir des répercussions sur l’autre unité.
La force du couplage dépend du nombre de connexions entre les unités.
Par exemple, une méthode avec un seul paramètre possède un niveau de couplage plus
faible avec ses clients qu’une méthode qui compte six ou sept paramètres.
La force du couplage dépend aussi de la visibilité et du type des connexions entre les
unités de programme.
Par exemple, un couplage explicite par l’intermédiaire d’arguments est préférable (plus
faible) à un couplage implicite par l’intermédiaire de variables globales (plus fort).
Il existe un certain nombre de formes typiques de couplage entre unités de programme.
Les formes suivantes, qui représentent des niveaux de couplage nul ou faibles, sont consid-
érées acceptables :

• Aucun couplage : les deux unités de programme ne sont aucunement liées entre
elles. Dans ce cas, on peut changer une unité de programme sans que cela n’ait
d’impact sur l’autre.

• Couplage par données simples : les deux unités de programme s’échangent


des données simples (types de base ou tableaux homogènes) par l’intermédiaire de
paramètres.

• Couplage par objets : les deux unités s’échangent des objets (structures de don-
nées complexes mais fonctionnellement cohésives) par l’intermédiaire de paramètres.

• Couplage par variables d’instance : les deux unités sont dans la même classe et
s’échangent de l’information par l’intermédiaire de variables d’instance de la classe.

Par contre, les formes de couplage suivantes sont généralement à éviter :

• Stamp ( data structure) coupling : Une structure de données est passée en


paramètre alors qu’un seul champ de cette structure est nécessaire.

• Couplage par variable globale : Les deux unités de programme communiquent


par l’intermédiaire de variables globales.

• Couplage de contrôle : Un module passe un indicateur de contrôle à l’autre


module pour lui indiquer ce qu’il doit faire. Cette forme de couplage n’est acceptable
que si l’indicateur de contrôle est un type spécifiquement défini pour cette tâche (par
exemple, type défini avec un enum).

En conclusion, l’objectif est de minimiser le plus possible le couplage entre deux unités
de programme, de rendre les liens clairs et explicites autant que possible.
Style et qualité du code 777

5 Refactoring
Qu’est-ce que le refactoring ?

Figure .4: Qu’est-ce que le refactoring?


Style et qualité du code 778

Refactoring (noun) a change made to the internal structure of software to make it


easier to understand and cheaper to modify without changing its observable behavior.
Source : «Refactoring—Improving the Design of Existing Code», Fowler, 1999
Refactor (verb) to restructure software by applying a series of refactorings without
changing its observable behavior.
Source : «Refactoring—Improving the Design of Existing Code», Fowler, 1999
Question : Sous quelles conditions peut-on faire du refactoring sans crainte de tout
briser — sans crainte de régresser?

Réponse : Lorsqu’on a des tests unitaires et que notre programme exécute avec succès
ces tests!
Style et qualité du code 779

6 Exemples et contre-exemples en Ruby


Exemple 1. Boucle définie vs. indéfinie
Problème : On veut calculer la somme des éléments d’un tableau a

Mauvais — Boucle indéfinie (while) pour un nombre fixe d’itérations


tot = 0
i = 0
while i < a . size
tot += a [ i ]
i += 1
end

Mieux — Boucle définie sur les index (each_index)


tot = 0
a . each_index do | i |
tot += a [ i ]
end

Mieux — Boucle définie sur les éléments (each)


tot = 0
a . each do | x |
tot += x
end

Encore mieux — Réduction


tot = a . reduce (0 , :+)
Style et qualité du code 780

Exemple 2. Factorisation de code répétitif


Problème : On veut calculer la somme de deux tableaux de longueurs différentes — en
utilisant 0 pour les valeurs manquantes du tableau plus court
Mauvais — Code répétitif. . . et contenant une erreur à cause d’un copier/coller mal
corrigé (laquelle?)
def additionner ( a , b )
if a . size <= b . size
c = Array . new ( b . size )
(0... a . size ). each do | i |
c[i] = a[i] + b[i]
end
( a . size ... c . size ). each do | i |
c[i] = b[i]
end
else
c = Array . new ( a . size )
(0... b . size ). each do | i |
c[i] = a[i] + b[i]
end
( b . size ... c . size ). each do | i |
c[i] = a[i]
end
end

c
end
Mieux — Élimination du code répétitif
def additionner ( a , b )
a , b = b , a if a . size > b . size
c = Array . new ( b . size )

(0... a . size ). each do | i |


c[i] = a[i] + b[i]
end
( a . size ... c . size ). each do | i |
c[i] = b[i]
end

c
end
Style et qualité du code 781

Exemple 3. Factorisation de code répétitif et réduction du cou-


plage
Problème : On veut calculer la somme de deux tableaux de longueurs différentes (bis)

Mauvais — Code répétitif


def additionner_element ( i , a , b , c )
ai = i < a . size ? a [ i ] : 0
bi = i < b . size ? b [ i ] : 0
c [ i ] = ai + bi
end

def additionner ( a , b )
n = [ a . size , b . size ]. max
c = Array . new ( n )

(0... n ). each do | i |
additionner_element ( i , a , b , c )
end

c
end
Style et qualité du code 782

Mauvais — Couplage inutilement fort pour additionner/additionnerElement relative-


ment au tableau c : on n’utilise que l’item i, pas tout c (stamp coupling)8
def element ( i , a )
i < a . size ? a [ i ] : 0
end

def additionner_element ( i , a , b , c )
c [ i ] = element (i , a ) + element (i , b )
end

def additionner ( a , b )
n = [ a . size , b . size ]. max
c = Array . new ( n )

(0... n ). each do | i |
additionner_element ( i , a , b , c )
end

c
end

8
http://en.wikipedia.org/wiki/Coupling_(computer_programming)
Style et qualité du code 783

Mieux — Couplage faible, bonne abstraction procédurale, code simple


def element ( i , a )
i < a . size ? a [ i ] : 0
end

def additionner ( a , b )
n = [ a . size , b . size ]. max
c = Array . new ( n )

(0... n ). each do | i |
c [ i ] = element (i , a ) + element (i , b )
end

c
end

Encore mieux — Couplage faible, bonne abstraction procédurale, code simple


def element ( i , a )
i < a . size ? a [ i ] : 0
end

def additionner ( a , b )
n = [ a . size , b . size ]. max

(0... n ). map do | i |
element (i , a ) + element (i , b )
end
end
Style et qualité du code 784

Exemple 4. Réduction du couplage


Problème : On veut calculer l’age d’une personne
Mauvais — Couplage de niveau stamp
class Personne
attr_reader : nom , : adresse , : date_naissance

def initialize ( nom , adresse , date_naissance )


@nom = nom
@adresse = adresse
@date_naissance = date_naissance
end

...
end

def age ( personne )


...( Time . now - personne . date_naissance ) ...
end

joe = Personne . new ( " Joe Bidon " , " ... " ,
Time . new (2000 , 12 , 12) )
puts age ( joe )
Mieux — Couplage de niveau objets (Time)
class Personne
... # Inchangee ...
...
end

def age ( date_naissance )


... ( Time . now - date_naissance ) ...
end

joe = Personne . new ( " Joe Bidon " , " ... " ,
Time . new (2000 , 12 , 12) )

puts age ( joe . date_naissance )


Style et qualité du code 785

Encore mieux — Méthode d’instance


class Personne
... # Inchangee ...
...
end

class Time
def age
... ( Time . now - self ) ...
end
end

joe = Personne . new ( " Joe Bidon " , " ... " ,
Time . new (2000 , 12 , 12) )

puts joe . date_naissance . age


Style et qualité du code 786

Exemple 5. Traitement d’un cas spécial


Problème : On veut calculer la somme des éléments d’un tableau, mais en identifiant et
traitant de façon spéciale le cas où a serait nil

Mauvais — Le cas spécial semble simplement une alternative «comme une autre»
def somme ( a )
if a . nil ?
0
else
a . reduce (0 , :+)
end
end

Mauvais — On se «débarrasse» du cas spécial au début du traitement, mais la valeur 0


reste quand même arbitraire :(
def somme ( a )
return 0 if a . nil ?

a . reduce (0 , :+)
end
Style et qualité du code 787

Mieux — Une méthode qui a comme précondition que a n’est pas nil = Principe «Fail
early, fail fast»
def somme ( a )
fail " *** Dans somme : a = nil !? " if a . nil ?

a . reduce (0 , :+)
end
Style et qualité du code 788

Exemple 6. Déclarations locales et simplification de code


Problème : On veut trier un tableau d’entiers (tri par sélection)

Mauvais — Variables déclarées inutilement de façon globale, initialisées sans besoin, nom
inutilement complexe pour une variable d’itération (index)
def trier ( a )
n = a . size
index_min = 0

(0.. n -1). each do | i |


index_min = i
( i +1.. n -1). each do | j |
if a [ j ] < a [ index_min ]
index_min = j
end
end

tmp = a [ i ]
a [ i ] = a [ index_min ]
a [ index_min ] = tmp
end
end

Mieux — Code simple, intervalle avec borne exclusive, variable introduite au point
d’utilisation, garde, instruction simple pour échanger
def trier ( a )
n = a . size

(0... n ). each do | i |
index_min = i
( i +1... n ). each do | j |
index_min = j if a [ j ] < a [ index_min ]
end
a [ index_min ] , a [ i ] = a [ i ] , a [ index_min ]
end
end
Style et qualité du code 789

Exemple 7. Duplication de code presque pareil. . . mais pas


tout à fait!
Problème : On veut créer un fichier temporaire avec un certain contenu, effectuer un
traitement sur le fichier, puis supprimer le fichier temporaire

Mauvais — Code répétitif


# Test 1
contenu = [ " abc " , " def " , " ghi " ]
File . open ( " foo . txt " , " w " ) do | fich |
fich . puts contenu
end
assert_equal " 3 foo . txt \ n " , % x { wc -l foo . txt }
assert_equal " 3\ n " , % x { wc -l < foo . txt }
FileUtils . rm_f " foo . txt "

# Test 2
contenu = [ " abc " , " def " , " ghi " ]
File . open ( " foo . txt " , " w " ) do | fich |
fich . puts contenu
end
assert_equal " 12 foo . txt \ n " , % x { wc -c foo . txt }
FileUtils . rm_f " foo . txt "
Style et qualité du code 790

Mieux — Code DRY avec bloc et yield


def avec_fichier ( nom_fichier , contenu )
File . open ( nom_fichier , " w " ) do | fich |
fich . puts contenu
end

yield

FileUtils . rm_f nom_fichier


end

# Test 1
avec_fichier " foo . txt " , [ " abc " , " def " , " ghi " ] do
assert_equal " 3 foo . txt \ n " , % x { wc -l foo . txt }
assert_equal " 3\ n " , % x { wc -l < foo . txt }
end

# Test 2
avec_fichier " foo . txt " , [ " abc " , " def " , " ghi " ] do
assert_equal " 12 foo . txt \ n " , % x { wc -c foo . txt }
end
Références

[Amd67] G.M. Amdahl. Validity of the single processor approach to achieving large
scale computing capabilities. In Proc. 1967 AFIPS Conf., volume 30, page
483. AFIPS Press, 1967.

[AMN02] D. Astels, G. Miller, and M. Novak. A Practical Guide to eXtreme program-


ming. The Coad Series. Prentice-Hall PTR, 2002.

[And00] G.R. Andrews. Foundations of Multithreaded, Parallel, and Distributed Pro-


gramming. Addison-Wesley, Reading, MA, 2000. [QA76.58A57 2000].

[AS85] H. Abelson and G.J. Sussman. Structure and Interpretation of Computer Pro-
grams. The MIT Press, McGraw-Hill Book Co., Cambridge, Ma., 1985.

[Bec00] K. Beck. Extreme Programming Explained—Embrace Change. Addison-Wesley,


2000.

[Bec01] K. Beck. Aim, fire. IEEE Software, 18(6):87–89, 2001.

[Bec03] K. Beck. Test-Driven Development—By Example. Addison-Wesley, 2003.

[BG98] K. Beck and E. Gamma. Test infected: Programmers love writing tests. Java
Report, 3(7):37–50, 1998.

[Bla04] C. Blaess. Scripts sous Linux—Shell Bash, Sed, Awk, Perl, Tcl, Tk, Python,
Ruby. Eyrolles, 2004.

[Ble96] G.E. Blelloch. Programming parallel algorithms. Communications of the ACM,


39(3):85–97, 1996.

[But14] P. Butcher. Seven Concurrency Models in Seven Weeks: When Threads Un-
ravel. Pragmatic Bookshelf, 2014.

[CAD+ 10] D. Chelimsky, D. Astels, Z. Dennis, A. Hellesoy, B. Helmkamp, and D. North.


The RSpec Book: Behaviour Driven Development with RSpec, Cucumber, and
Friends. The Pragmatic Bookshelf, 2010.

791
Bibliographie 792

[CG89] N. Carriero and D. Gelernter. How to write parallel programs—a guide to


the perplexed. ACM Computing Surveys, 21(3):323–357, Sept. 1989. [Tiré
de [ST95]].

[CG90] N. Carriero and D. Gelernter. How to Write Parallel Programs—A First


Course. MIT Press, 1990.

[Cha09] S. Chacon. Pro Git. Apress, 2009.

[DG08] J. Dean and S. Ghemawat. MapReduce: Simplified data processing on large


clusters. Commun. ACM, 51(1):107–113, Jan. 2008.

[Dix11] P. Dix. Service-Oriented Design with Ruby and Rails. Addison-Wesley Profes-
sional Ruby Series, 2011.

[FLR98] M. Frigo, C.E. Leiserson, and K.H. Randall. The implementation of the Cilk-5
multithreaded language. In PLDI ’98. ACM, 1998.

[Fos95] I. Foster. Designing and Building Parallel Programs. Addison-Wesley, 1995.


http://www-unix.mcs.anl.gov/dbpp.

[Fow11] M. Fowler. Domain-Specific Languages. Addison-Wesley, 2011.

[GGKK03] A. Grama, A. Gupta, G. Karypis, and V. Kumar. Introduction to Parallel


Computing (Second Edition). Addison-Wesley, 2003.

[Gol89] A. Goldberg. Smalltalk-80: The Language. Addison-Wesley, 1989.

[Gra07] J.R. Graham. Integrating parallel programming techniques into traditional


computer science curricula. Inroads — SIGCSE Bulletin, 39(4):75–78, Dec,
2007.

[GUD96] M. Gengler, S. Ubéda, and F. Desprez. Initiation au parallélisme—Concepts,


architectures et algorithmes. Masson, 1996. [QA76.58G45].

[Gus88] J.L. Gustafson. Reevaluating Amdahl’s law. Communications of the ACM,


31(5):532–533, May 1988.

[Har13] M. Hartl. Ruby on Rails Tutorial (Second Edition). Addison-Wesley Profes-


sional Ruby Series, 2013.

[HT03] A. Hunt and D. Thomas. Pragmatic Unit Testing In Java with JUnit. The
Pragmatic Bookshelf, 2003.

[Jac75] M.A. Jackson. Principles of Program Design. Academic Press, 1975.

[Jac83] M.A. Jackson. System Development. Prentice-Hall, 1983.


Bibliographie 793

[JaJ92] J. JaJa. An Introduction to Parallel Algorithms. Addison-Wesley Publishing


Company, 1992. [QA76.58J34].

[KF90] A.H. Karp and H.P Flatt. Measuring parallel processor performance. Com-
munications of The ACM, 33(5):539–543, May 1990.

[KGGK94] V. Kumar, A. Grama, A. Gupta, and G. Karypis. Introduction to Parallel


Computing—Design and Analysis of Algorithms. The Benjamin/Cummings
Publishing Company, 1994. [QA76.58I58].

[KGGK03] V. Kumar, A. Grama, A. Gupta, and G. Karypis. Introduction to Parallel


Computing (Second Edition). Addison-Wesley, 2003.

[KKT01] J. Keller, C. Kessler, and J. Traff. Practical PRAM Programming. John Wiley
& Sons, Inc., 2001.

[KKWZ15] H. Karau, A. Konwinski, P. Wendell, and M. Zaharia. Learning Spark. O’Reilly,


2015.

[Lea00] D. Lea. Concurrent Programming in Java—Design Principles and Patterns


(Second Edition). Addison-Wesley, 2000.

[Lew15] A. Lewis. Rails Crash Course—A No-Nonsense Guide to Rails Development.


No Starch Press, 2015.

[LG86] B. Liskov and J. Guttag. Abstraction and specification in program development.


MIT Press, 1986.

[LS09] C. Lin and L. Snyder. Principles of Parallel Programming. Addison Wesley,


2009.

[MB00] R. Miller and L. Boxer. Algorithms Sequential & Parallel. Prentice-Hall, 2000.
[QA76.9A43M55].

[MPT78] M.D. McIlroy, E.N. Pinson, and B.A. Tague. Unix time-sharing system for-
ward. The Bell System Technical Journal, 57(6–2), 1978.

[MRR12] M. McCool, A.D. Robison, and J. Reinders. Structured Parallel


Programming—Patterns for Efficient Computation. Morgan Kaufmann, 2012.

[MS+ 85] J.R. McGraw, S. Skedzielewski, et al. SISAL: Streams and iteration in a
single assignment language—language reference manual version 1.2. Technical
Report M-146, Lawrence Livermore National Laboratory, 1985.

[MSM05] T.G. Mattson, B.A. Sanders, and B.L. Massingill. Patterns for Parallel Pro-
gramming. Addison-Wesley, 2005.

[OW04] S. Oaks and H. Wong. Java Threads—Third Edition. O’Reilly, 2004.


Bibliographie 794

[Pac97] P.S. Pacheco. Parallel Programming with MPI. Morgan Kaufman Publ., 1997.

[Qui03] M.J. Quinn. Parallel Programming in C with MPI and OpenMP. McGraw-Hill,
2003.

[Ray04] E.S. Raymond. The Art of UNIX Programming. Addison-Wesley, 2004.

[Rei07] J. Reinders. Intel Threading Building Blocks: Outfitting C++ for Multi-Core
Processor Parallelism. O’Reilly Media, 2007.

[RK03] P.N. Robillard and P. Kruchten. Software Engineering Process with the UP-
EDU. Addison-Wesley, 2003.

[Rob09] A.R. Robison. Intel threading building blocks. www.research.ibm.com/


haifa/Workshops/padtad2009/present/TBB-PADTad-2009.ppt, July 2009.

[RTH13] S. Ruby, D. Thomas, and D.H. Hansson. Agile Web Development with Rails
4. THe Pragmatic Bookshelf, 2013.

[SMR09] M.J. Sottile, T.G. Mattson, and C.E. Rasmussen. Introduction to Concurrency
in Programming Languages. Chapman and Hall/CRC, 2009.

[SSRB00] D. Schmidt, M. Stal, H. Rohnert, and F. Buschmann. Pattern-oriented soft-


ware architecture Vol. 2—Patterns for Concurrent and Networked Objects.
John Wiley & Sons, Ltd., 2000.

[ST95] D.B. Skillicorn and D. Talia. Programming Languages for Parallel Processing.
IEEE Computer Society Press, 1995.

[Ste84] G.L. Steele Jr. Common LISP: The Language. Digital Press, 1984.

[Swi08] T. Swicegood. Pragmatic Version Control Using Git. The Pragmatic Book-
shelf, 2008.

[Tho96] S. Thompson. Haskell—The Craft of Functional Programming. Addison-


Wesley, 1996.

[WCS96] L. Wall, T. Christiansen, and R.L. Schwartz. Programming Perl (2nd Edition).
O’Reilly, 1996.

[Whi15] T. White. Hadoop—The Definitive Guide (4th Edition). O’Reilly, 2015.

Vous aimerez peut-être aussi