Hadoop Map/Reduce est un framework de calcul distribué inspiré du paradigme fonctionnel. Dans cet article, nous allons voir dans un premier temps la théorie, ce qu’est ce paradigme, puis la pratique, en écrivant en job complet pour Hadoop. Un précédent article expliquait comment installer un cluster Hadoop.
Un premier pas dans le fonctionnel
Avant de commencer par les spécificités d’Hadoop, il est utile de comprendre ce que signifient map() et reduce() en général. Ce sont des abstractions que vous utilisez probablement tous les jours, même si vous n’avez pas nécessairement l’habitude de les identifier ni de les nommer. Ces habitudes s’installeront sans doute avec Java 8.
map()
Soient deux types A et B ; si on possède une fonction capable de transformer A en B, alors l’opération map() permet de transformer une collection de A en une collection de B.
Plus concrètement, si on possède une classe Person avec une fonction getFirstname() retournant son prénom, à partir de la version 8 de Java, on pourrait écrire :
persons.stream().map(person -> person.getFirstName())
Alors que pour les versions précédentes, nous devions écrire :
Collection<String> firstnames = new ArrayList<String>(persons.size()); for (Person person : persons) { firstnames.add(person.getFirstname()); }
L’opération map() permet d’abstraire l’implémentation réelle de l’application d’une fonction de transformation sur un ensemble d’éléments. On souhaitera avoir une transformation pure, c’est à dire dont le seul effet est de retourner des résultats et ne modifie pas un contexte plus ou moins implicite. Cette pureté va permettre différentes implémentations en fonction de la volumétrie des données. Dans les cas simples, sur un faible volume de données, une boucle séquentielle est sûrement la meilleure approche. Dès que le volume va grossir, paralléliser en local les transformations peut améliorer le temps de réponse mais il faut alors gérer la synchronisation entre les threads. Distribuer le traitement sur plusieurs machines permet de traiter des volumes encore plus gros mais il faut alors gérer les cas de pannes. C’est ce que fait Hadoop Map/Reduce. Etant donné que l’implémentation n’est pas à la charge du client, il n’a plus à se soucier ni du nombre de fois ni de l’endroit où les transformations ont eu lieu. Seul le résultat compte.
reduce()
L’opération reduce() se réalise aussi sur un ensemble d’éléments mais permet le partage d’un état local au détriment de la parallélisation des opérations.
Faire une somme est sûrement l’exemple le plus concret. Supposons que nos précédentes Person soient des employés, on peut vouloir calculer le salaire total en se basant sur les rémunérations personnelles getSalary(). Le partage d’un état local est nécessaire pour le besoin, on ne peut donc pas uniquement se baser sur map().
La logique du reduce() en lui-même est dans le cadre d’Hadoop peu intéressante et entièrement à la charge de développeur. Mais il est important de voir qu’un reduce sur une collection (un ensemble d’éléments) peut être envisagé comme une opération de transformation agissant sur un élément : la collection dans son ensemble. Il est ainsi possible de faire un map de reduces lorsque plusieurs collections doivent être transformées en parallèle. C’est ce que fait le Reducer dans Hadoop. Cela permet encore une fois de profiter de la gestion de la parallélisation et de distribution des tâches gérées par le framework.
Hadoop Map/Reduce gère des couples clef/valeur
Hadoop Map/Reduce est une implémentation spécifique de map() et reduce() pour faire du traitement distribué en se basant sur une système de fichiers distribué (HDFS). Conceptuellement, une contrainte a été ajoutée : le framework va travailler sur des collections de paires clef/valeur. Le terme collection est considéré ici dans un sens large : tous les éléments ne sont bien sur pas chargés en mémoire.
Hadoop Map/Reduce lancera un certain nombre de tâches pour réaliser le traitement : des Mappers et des Reducers. Chaque tâche possède sa propre JVM et effectuera séquentiellement plusieurs appels de map() ou de reduce() respectivement. La définition de la méthode de map() et reduce() diffère un peu de la définition fonctionnelle classique. La méthode de transformation utilisée par le Mapper sera map() et la méthode de transformation utilisée par le Reducer sera reduce(). Pour un style véritablement fonctionnel, map et reduce devraient recevoir la logique à appliquer par une fonction passée en argument mais Hadoop Map/Reduce est implementé en Java qui historiquement est un langage objet et non fonctionnel.
Soit 6 types : A, B, C ,D, E, F. L’étape Mapper va transformer une collection de (A,B) en une collection de (C,D). Ensuite, toutes les différentes valeurs D associées à une même clef C vont être regroupées. Il s’agit du shuffle-sort. Et l’étape Reducer va transformer une collection de (C,collection<D>) en une collection de (E,F). Chaque Reducer est garanti de traiter en même temps toutes les valeurs D associées à une même clef C et de recevoir les clefs C dans leur ordre naturel, mais pas nécessairement de recevoir toutes les clefs puisque le travail est partagé.
Les types sont arbitraires car c’est au développeur de spécifier ce qu’il souhaite. (A,B) est lié à la manière dont le fichier est lu. (C,D) est un format d’échange intermédiaire entre map et reduce. Et (E,F) est le format final, pouvant être relu par un autre job.
Le nombre de Mappers est déterminé par le framework en fonction des fichiers lus. Par défaut, il y a une tâche Mapper par bloc composant chaque fichier d’entrée. Étant donné que pour chaque tâche une JVM est lancée, il est pertinent de faire attention à la fois aux nombres de fichiers et à la taille des blocs afin de minimiser le surcoût liée à la parallélisation, la distribution et la gestion des erreurs. C’est une problématique qui peut nécessiter un travail commun de la part du développeur et de l’administrateur.
Le nombre de Reducers est quant à lui explicité manuellement et est par défaut 1 si non précisé. Ce nombre doit être spécifié en fonction du volume de (C,D) mais également à leur distribution. En effet, par défaut, le Reducer utilisé pour une donnée émise par un Mapper est choisi en fonction du hashcode de la clef. Si la fonction de hashage est non uniforme ou si une clef est beaucoup plus présente qu’une autre clef, la charge entre les reducers peut être mal équilibrée. C’est encore une fois une problématique devops où chaque partie a un rôle critique à jouer.
Un job se compose nécessairement d’un étape de map() mais celle-ci peut être une identité, c’est à dire ne changeant pas les données. La partie reduce() est cependant optionnelle. Certaines problématiques peuvent être traitées exclusivement en parallèle mais pas toutes. On parlera de job Map-only.
Qu’est ce qu’un job?
Une job est essentiellement deux fichiers. Le premier est un jar (archive java) qui contient le code devant être exécuté, propre à Hadoop. Et le second est un fichier xml contenant sous forme de clef/valeur la configuration du job. Cela dit, ce fichier est très rarement renseigné tel quel. Les APIs vont vous permettre de configurer celui-ci de façon transparente mais il est important de comprendre que c’est par le biais de ce fichier xml que de la configuration (statique) est passée aux différentes tâches Mapper et Reducer fonctionnant sur des machines différentes.
Enfin, des APIs
Hadoop Map/Reduce a un historique. La version 2 est désormais disponible mais dès la version 1, il existait déjà deux APIs permettant de faire sensiblement la même chose.
L’ancienne API est dans le package org.apache.hadoop.mapred. Elle fut un moment dépréciée, mais ce n’est plus le cas. Étant donné que beaucoup de documentations mentionnent celle-ci, elle sera utilisée durant cet article. Il faut cependant savoir que org.apache.hadoop.mapreduce est une alternative, plus récente. Dans la mesure où les concepts sont assimilés, la migration d’une API vers une autre n’est pas une grande difficulté en soi.
Définir un job
Traditionnellement, le premier programme dans une nouvelle technologie cherche à afficher “Hello World”. Dans le monde d’Hadoop, une plate-forme de traitement de données, cela n’a pas de sens. Le premier programme est en fait un wordcount : calculer le nombre d’occurrences de chaque mot.
La partie map() va lire les fichiers ligne à ligne et émettre des couples (mot,1). La partie reduce() recevra ensuite l’ensemble des occurrences associé à chaque mot (celui-ci étant la clef) et effectuera la somme. L’utilisation du combiner sera expliqué en fin d’article.
Pour la définition du job, il est considéré comme une bonne pratique d’étendre la classe Configured et d’implémenter l’interface Tool. Cela permet de réutiliser le parsing d’options génériques à Hadoop et il est ainsi possible de préciser au lancement du job n’importe quel clef/valeur devant se retrouver au final dans le fichier xml de configuration. Par exemple, le nombre de Reducer peut être augmenté à 4 en ajoutant simplement “-D mapred.reduce.tasks=4”. Il faut bien noter l’espace entre le D et la propriété. Il ne s’agit pas de propriétés systèmes propres à la JVM, qui n’ont aucun intérêt ici car le traitement est effectué sur d’autres jvms. La fonction main utilise ToolRuner et ainsi dans la méthode run() la configuration récupérée par getConf() possède bien la configuration additionnelle présente sur le ligne de commande.
Le reste de la configuration est à connaître mais est essentiellement sans surprise.
public class WordCount extends Configured implements Tool { public static final String REMOVE_KEY = "wordcount.remove.regex"; public static void main(String[] args) throws Exception { int res = ToolRunner.run(new Configuration(), new WordCount(), args); System.exit(res); } public int run(String[] args) throws Exception { // préciser une classe permet au client de savoir // quel est le jar qui doit être envoyé au cluster JobConf conf = new JobConf(getConf(), WordCount.class); conf.setJobName("wordcount"); // on précise les types des données // sauf pour l’entrée du Mapper qui est à la charge de l’input format conf.setMapOutputKeyClass(Text.class); conf.setMapOutputValueClass(IntWritable.class); conf.setOutputKeyClass(Text.class); conf.setOutputValueClass(IntWritable.class); // on définit le mapper, le combiner et le reducer conf.setMapperClass(Map.class); conf.setCombinerClass(Reduce.class); conf.setReducerClass(Reduce.class); // et comment les données sont lues et écrites conf.setInputFormat(TextInputFormat.class); conf.setOutputFormat(TextOutputFormat.class); // enfin on parse les arguments pour un argument custom List<String> other_args = new ArrayList<String>(); for (int i = 0; i < args.length; ++i) { if ("-remove".equals(args[i])) { conf.set(REMOVE_KEY, args[++i]); } else { other_args.add(args[i]); } } // et les fichiers à lire et la destination des résultats FileInputFormat.setInputPaths(conf, new Path(other_args.get(0))); FileOutputFormat.setOutputPath(conf, new Path(other_args.get(1))); // puis on lance le job JobClient.runJob(conf); return 0; } }
Le format d’échange : Writable
Les données échangées doivent utiliser le mécanisme de sérialisation d’Hadoop et être des Writables. Les types primitifs et les String sont gérés par défaut, IntWritable et Text dans cet exemple sont des classes fournies par Hadoop. Mais comme le montrera le Mapper et le Reducer, c’est bien au développeur de wrapper et déwrapper les données. S’il est nécessaire d’avoir des types plus complexes alors il suffit de créer une classe implémentant Writable et encapsulant, par exemple, d’autres Writables déjà disponibles. Les clefs devront implémenter WritableComparable puisqu’elles doivent être comparables afin de pouvoir être regroupées durant le shuffle-sort.
Le Mapper et le Reducer
L’API pour le Mapper et le Reducer sont très proches. Dans les deux cas, il faut implémenter une interface Mapper ou Reducer puis étendre la classe abstraite MapReduceBase afin d’éviter de devoir implémenter à vide les deux fonctions du cycle de vie : configure() et close().
Le Mapper reçoit en value chaque ligne des fichiers une par une. Pour implémenter un wordcount, le Mapper découpe chaque ligne et émet chaque mot trouvé un par un. Sa méthode configure() permet de récupérer le paramètre renseigné précédemment afin de ne pas compter certains mots qui correspondent à une expression régulière. La réutilisation des instances Writables permet de soulager la gestion de la mémoire. Et enfin, un système de compteurs permet en fin de job d’obtenir un aperçu de son fonctionnement sans avoir à générer et parser des millions de logs.
public class Map extends MapReduceBase implements Mapper<LongWritable, Text, Text, IntWritable> { static enum Counters { REMOVED } private final static IntWritable one = new IntWritable(1); private Text word = new Text(); private Pattern pattern; @Override public void configure(JobConf job) { String remove = job.get(WordCount.REMOVE_KEY); if (remove != null) { pattern = Pattern.compile(remove); } } @Override public void map(LongWritable key, Text value, OutputCollector<Text, IntWritable> output, Reporter reporter) throws IOException { // .toString() permet de récupérer la String wrapper par Text String line = value.toString().toLowerCase(); StringTokenizer tokenizer = new StringTokenizer(line); while (tokenizer.hasMoreTokens()) { word.set(tokenizer.nextToken()); if (pattern != null && pattern.matcher(line).matches()) { reporter.incrCounter(Counters.REMOVED, 1); } else { output.collect(word, one); } } } }
Le Reducer s’explique de lui-même, l’API présentant peu de surprise une fois celle du Mapper comprise.
public class Reduce extends MapReduceBase implements Reducer<Text, IntWritable, Text, IntWritable> { @Override public void reduce(Text key, Iterator<IntWritable> values, OutputCollector<Text, IntWritable> output, Reporter reporter) throws IOException { int sum = 0; while (values.hasNext()) { // .get() permet de récupérer le Integer wrapper par IntWritable sum += values.next().get(); } output.collect(key, new IntWritable(sum)); } }
Et le combiner?
Le combiner est une optimisation de performance. Il utilise la même API que le Reducer, consomme une partie des données en sortie d’une tâche de Mapper et doit produire en sortie le même format. Le framework est responsable de son appel éventuel et potentiellement multiple. Le Mapper et le Reducer ne sont pas conscients de son existence. Son appel, optionnel et potentiellement multiple, doit donc ne pas modifier la logique de traitement.
Dans le cadre du wordcount, il permet de merger en avance des instances d’un même mot. Transformant [(mot,1),(mot,1),(mot,1)] en [(mot,3)] , il permet ainsi de réduire considérablement le volume de données à transférer depuis la tâche Mapper vers la tâche Reducer.
Il reste plus qu’à lancer
Le lancement ressemble à l’exécution standard d’un jar.
hadoop jar wordcount.jar fr.programmez.WordCount /a/lire /a/ecrire -remove le
La fin du début
Hadoop est un sujet vaste et cet article ne prétend pas pouvoir tout présenter. Il serait possible d’écrire des livres entiers sur ce sujet et cela a été fait. “Hadoop: The Definitive Guide” par Tom White est la référence à consulter.
Il est cependant possible de lister des sujets à creuser pour ceux souhaitant aller plus loin. Il faut mentionner l’existence d’un cache distribué permettant d’optimiser la lecture de fichiers de références couramment utilisés. Le pattern “secondary sort” est intéressant pour mieux comprendre le fonctionnement interne de la plate-forme. Maven est un outil permettant de simplifier la gestion de dépendances et MRunit une librairie pour écrire plus facilement vos tests unitaires MapReduce.
Tout cela dit, même si le potentiel de l’API présentée est énorme, il faut aussi se poser la question du niveau d’abstraction. D’autres outils comme Pig, Hive ou Cascading permettent de simplifier considérablement le développement. Mais la présentation de chaque outil séparément pourrait faire l’objet d’un article en soi.
Dernier mot, tout projet, même informatique, est avant tout une aventure humaine. Hadoop possède des mailing lists et un channel irc. Des groupes d’utilisateurs existent aussi tout autour de la planète, la France n’étant pas une exception (http://hugfrance.fr/).