Séance 6

MapReduce sur une machine

Rémi Sharrock

BGD701 — Télécom Paris

Objectifs

  • Comprendre le modèle MapReduce et pourquoi il a été inventé
  • Écrire un moteur MapReduce complet, à partir de rien
  • Comprendre le shuffle, cœur du problème
  • Paralléliser avec multiprocessing, et mesurer le gain

Le problème de départ

10 000 fichiers texte : compter les occurrences de chaque mot.

compteur = {}
for nom in fichiers:
    for mot in open(nom, encoding="utf-8").read().split():
        mot = mot.lower()
        compteur[mot] = compteur.get(mot, 0) + 1
  • Séquentiel : un seul cœur travaille
  • Tout en mémoire : des dizaines de millions d’entrées

Google, 2004 : imposer un modèle très contraint pour automatiser tout le reste. C’est l’ancêtre direct d’Hadoop.

L’ancêtre Unix

Compter les mots en une ligne

cat corpus/*.txt \
    | grep -oE '[[:alpha:]]+' \
    | awk '{print tolower($0)}' \
    | sort \
    | uniq -c \
    | sort -rn | head -8

grep + awk = le map. sort = le shuffle. uniq -c = le reduce.

Attention aux accents

La recette classique, tr -c '[:alpha:]' '\n', détruit les accents sous Linux : tr y travaille octet par octet, et é occupe deux octets.

« L’été, déjà là » → l t d j l

grep -oE + awk : correct sur les machines de l’école et sur Mac.

Ce qui bloque

  • Un disque, un jeu de processeurs
  • Aucune reprise sur panne : mort à 90 %, tout est perdu
  • Impossible à répartir sur cent machines

MapReduce, c’est map | sort | reduce à l’échelle d’un centre de données.

Le modèle

Des couples clé-valeur

  • ("chat", 1) : la clé dit de quoi on parle, la valeur ce qu’on en sait
  • map : un morceau de l’entrée → des couples (clé, valeur)
  • shuffle : rassembler toutes les valeurs de même clé (le système)
  • reduce : une clé et toutes ses valeurs → le résultat

Le comptage de mots

def fonction_map(nom_fichier, contenu):
    """Emet (mot, 1) pour chaque mot rencontre."""
    for mot in contenu.split():
        mot = mot.strip(".,;:!?\"'()[]").lower()
        if mot:
            yield (mot, 1)

def fonction_reduce(cle, valeurs):
    """Recoit ("chat", [1, 1, 1, 1]) et renvoie 4."""
    return sum(valeurs)

yield : un générateur qui livre les couples un par un, sans tout garder en mémoire.

Sur un exemple minuscule

Phase Contenu
Map (f1 : le chat dort) (le,1) (chat,1) (dort,1)
Map (f2 : le chien dort) (le,1) (chien,1) (dort,1)
Shuffle le:[1,1] chat:[1] dort:[1,1] chien:[1]
Reduce le:2 chat:1 dort:2 chien:1

Les tâches map sont indépendantes : n’importe où, dans n’importe quel ordre, relançables en cas de panne.

Le moteur

def mapreduce(fichiers, fonction_map, fonction_reduce):
    # ---- Phase MAP ----
    intermediaires = []
    for nom in fichiers:
        with open(nom, encoding="utf-8", errors="ignore") as f:
            contenu = f.read()
        intermediaires.extend(fonction_map(nom, contenu))

    # ---- Phase SHUFFLE : regrouper par cle ----
    groupes = {}
    for cle, valeur in intermediaires:
        groupes.setdefault(cle, []).append(valeur)

    # ---- Phase REDUCE ----
    resultat = {}
    for cle, valeurs in groupes.items():
        resultat[cle] = fonction_reduce(cle, valeurs)
    return resultat

Changer les fonctions, pas le moteur

Problème map émet reduce fait
Comptage de mots (mot, 1) la somme
Longueurs de mots (len(mot), 1) la somme
Index inversé (mot, fichier) la liste des fichiers
Maximum par catégorie (cat, valeur) le maximum
Doublons (ligne, 1) garde si somme >1> 1

Le shuffle

Qui traite quel mot ?

Avec cent machines, les couples sont éparpillés. Il faut décider à l’avance quel réducteur traite quelle clé, sans se concerter.

𝚛𝚎𝚍𝚞𝚌𝚝𝚎𝚞𝚛=𝚑𝚊𝚜𝚑(𝚌𝚕𝚎)mod⁡R \texttt{reducteur} = \texttt{hash(cle)} \bmod R

Mot le chat chien dort
Réducteur, sur un Mac 1 1 0 1
Réducteur, sur tp-1a201-18 1 1 0 1

hashlib, jamais hash() : salé au hasard à chaque démarrage.

M×RM \times R fichiers

  • MM tâches map (une par fichier), RR tâches reduce
  • Chaque map écrit RR fichiers, un par réducteur
  • Le réducteur rr lit les MM fichiers qui lui sont destinés

M×RM \times R fichiers intermédiaires : le shuffle est la phase la plus coûteuse.

Le combiner

Au lieu d’écrire mille fois ("le", 1), écrire une fois ("le", 1000).

Fichier Sans combiner Avec combiner Facteur
1 Mo 2,22 Mo 0,31 Mo ≈ 7
8 Mo 17,61 Mo 0,55 Mo ≈ 32

Un couple par mot distinct. Plus les données sont grosses, plus le gain est grand.

Seulement si reduce le permet

Le combiner réduit en deux temps. Il faut que ce soit sans effet :

  • associative : (2+3)+4=2+(3+4)(2 + 3) + 4 = 2 + (3 + 4)
  • commutative : 2+3=3+22 + 3 = 3 + 2

La moyenne ne l’est pas

Moyenne de (2,2,2)(2, 2, 2) et de (10)(10) : 4. Moyenne des deux moyennes : (2+10)/2=(2 + 10)/2 = 6. On transporte (somme, effectif), on divise à la fin.

Paralléliser

Cœurs et processus

  • Un cœur : une unité de calcul. Machine de salle mesurée : i7-8700, 6 physiques, 12 logiques
  • Un processus : un programme en cours, avec sa propre mémoire
  • GIL : les threads Python ne calculent jamais en même temps → il faut des processus

multiprocessing.Pool(p) : pp ouvriers. pool.map(f, liste) = un [f(x) for x in liste] en parallèle, qui attend tout le monde.

Le moteur parallèle

    p = int(sys.argv[1])           # nombre de processus
    fichiers = sys.argv[2:]
    nb_maps = len(fichiers)
    os.makedirs(DOSSIER, exist_ok=True)

    with multiprocessing.Pool(p) as pool:
        pool.map(tache_map, list(enumerate(fichiers)))
        t_map = time.perf_counter()
        pool.map(tache_reduce, [(r, nb_maps) for r in range(R)])

Deux pool.map successifs : aucun réducteur ne commence avant la fin de tous les maps. C’est la barrière.

Deux règles impératives

  • if __name__ == "__main__": obligatoire. Sur Mac (spawn), sans lui : 958 RuntimeError en 20 s, jamais fini. À l’école (fork, Python 3.11) : ça marche… jusqu’au jour où l’on change de machine
  • Tout passer en argument. Un ouvrier n’entre jamais dans le bloc __main__ : une variable globale calculée là lui est inconnue

/tmp est partagé entre utilisateurs : un dossier à votre nom, avec getpass.getuser().

Mesuré sur une machine de salle

8 fichiers de 1 Mo, R=4R = 4, tp-1a201-18

pp 1 2 3 4 5 6 8
total (s) 4,75 2,41 1,84 1,20 1,21 1,20 0,67
accélération 1,0 2,0 2,6 4,0 3,9 4,0 7,1

4, 5 et 6 processus : le même temps. Pourquoi ?

Des vagues de tâches

Une tâche map = 0,54 s. Le processus le plus chargé fixe le temps.

pp Répartition des 8 tâches Vagues Map
4 2 + 2 + 2 + 2 2 1,08 s
5 2 + 2 + 2 + 1 + 1 2 1,09 s
6 2 + 2 + 1 + 1 + 1 + 1 2 1,08 s
8 1 + 1 + … + 1 1 0,55 s

L’accélération est limitée par le nombre de tâches. Pour 12 cœurs, il faut au moins 12 tâches.

Ce qui empêche d’atteindre 8

  • Le reduce : seulement R=4R = 4 tâches, donc au plus 4 fois plus vite
  • La barrière entre map et reduce
  • Le coût de création des processus

C’est la part séquentielle du calcul. En séance 7 : la loi d’Amdahl.

À vous

Les exercices

  • 6.1 — Le moteur. Vérifier à la main, puis 18 810 mots distincts, puis comparer avec Unix.
  • 6.2 — D’autres fonctions. Longueurs, index inversé, moyenne par initiale.
  • 6.3 — Le partitionnement. Trois propriétés vérifiées par assert.
  • 6.4 — Le combiner. Mesurer le gain, sur 1 Mo puis 8 Mo.
  • 6.5 — L’accélération. Sur une machine de l’école, 1 à 8 processus.
  • 6.6 — La moyenne. Un bug invisible sur le corpus.

Ce qu’il faut retenir

  • MapReduce = map | sort | reduce à grande échelle ; le sort, c’est le shuffle
  • On écrit map et reduce, le système fait le reste
  • hash(cle) % R : déterministe, sans concertation
  • M×RM \times R fichiers : le shuffle est la phase la plus coûteuse
  • Le combiner divise le volume, si reduce est associative
  • La barrière est la part séquentielle
  • Plus de tâches que de processus, sinon les cœurs attendent