foldByKey, aggregateByKey et combineByKey en Spark

reduceByKey impose le même type en entrée et en sortie, ce qui rend une moyenne impossible. Les trois agrégateurs qui lèvent cette contrainte.

Calculer une somme par clé avec reduceByKey est immédiat. Calculer une moyenne par clé ne l’est pas — et la raison est structurelle : reduceByKey exige que le résultat soit du même type que les valeurs d’entrée. Pour une moyenne, il faut transporter deux informations (une somme et un compte), donc changer de type en cours de route.

C’est exactement le problème que résolvent foldByKey, aggregateByKey et combineByKey. Trois outils, une même famille, un ordre croissant de puissance et de verbosité. On les compare sur le même exemple.

Le jeu de données

Des notes par étudiant :

scala
val notes = sc.parallelize(List(
  ("Ali", 15), ("Sara", 12), ("Ali", 9),
  ("Yassine", 18), ("Sara", 16), ("Ali", 12)
))

Objectif : la moyenne par prénom. Résultat attendu :

Ali     : 12.0   // (15 + 9 + 12) / 3
Sara    : 14.0   // (12 + 16) / 2
Yassine : 18.0   // 18 / 1

Pourquoi reduceByKey ne peut pas

scala
notes.reduceByKey(_ + _)
// ("Ali", 36), ("Sara", 28), ("Yassine", 18)

La somme, oui. Mais le compte a disparu — il aurait fallu le porter en même temps. La signature l’interdit :

scala
def reduceByKey(func: (V, V) => V): RDD[(K, V)]

Entrée V, sortie V. Même type. Pour une moyenne, il faut passer de Int à (Int, Int) — la famille aggregateByKey existe pour ça.

La famille, en une table

MéthodeValeur initialeType de sortieVerbosité
reduceByKeyNonIdentique à l’entréeMinimale
foldByKeyOui (élément neutre)Identique à l’entréeFaible
aggregateByKeyOuiDifférent possibleMoyenne
combineByKeyNon — une fonction de créationDifférent possibleMaximale

Toutes les quatre effectuent le combine local avant le shuffle — c’est la propriété qui les distingue de groupByKey et qui fait leur intérêt en performance.

foldByKey : reduceByKey avec un élément neutre

scala
notes.foldByKey(0)(_ + _).collect()
// ("Ali", 36), ("Sara", 28), ("Yassine", 18)

La valeur 0 est l’élément neutre de l’opération, appliqué au démarrage de chaque partition. L’intérêt sur reduceByKey est étroit mais réel :

scala
// Le maximum, avec une borne inférieure explicite
notes.foldByKey(Int.MinValue)(math.max).collect()

// Une multiplication, dont le neutre est 1 et non 0
scores.foldByKey(1)(_ * _).collect()

Attention : la valeur initiale est appliquée par partition, pas une fois globalement. Avec foldByKey(10)(_ + _) sur trois partitions contenant la même clé, vous ajoutez 30, pas 10. C’est pour cette raison que l’élément doit être neutre pour l’opération — sinon le résultat dépend du nombre de partitions.

aggregateByKey : changer de type, enfin

C’est l’outil de la moyenne. Sa signature demande deux fonctions :

scala
def aggregateByKey[U](valeurInitiale: U)(
  dansLaPartition: (U, V) => U,      // accumule une valeur dans l'accumulateur
  entrePartitions: (U, U) => U       // fusionne deux accumulateurs
): RDD[(K, U)]

L’accumulateur U peut être de n’importe quel type. Pour une moyenne, c’est un couple (somme, compte) :

scala
val moyennes = notes
  .aggregateByKey((0, 0))(
    // dans la partition : on ajoute une note à l'accumulateur
    (acc, note) => (acc._1 + note, acc._2 + 1),
    // entre partitions : on additionne deux accumulateurs
    (a, b) => (a._1 + b._1, a._2 + b._2)
  )
  .mapValues { case (somme, compte) => somme.toDouble / compte }

moyennes.collect().foreach(println)
// (Ali, 12.0)
// (Sara, 14.0)
// (Yassine, 18.0)

Les deux fonctions ont des rôles bien distincts, et c’est le point qui bloque tout le monde au début :

dansLaPartition mélange un accumulateur et une valeur brute. entrePartitions fusionne deux accumulateurs. Les types diffèrent, d’où les deux fonctions — et c’est ce qui permet de ne transporter que (somme, compte) sur le réseau au lieu de toutes les notes.

Autres usages classiques d’aggregateByKey
scala
// Collecter les valeurs distinctes par clé, sans groupByKey
notes.aggregateByKey(Set.empty[Int])(_ + _, _ ++ _)

// Min et max simultanément
notes.aggregateByKey((Int.MaxValue, Int.MinValue))(
  (acc, v) => (math.min(acc._1, v), math.max(acc._2, v)),
  (a, b)   => (math.min(a._1, b._1), math.max(a._2, b._2))
)

// Statistiques complètes : compte, somme, somme des carrés → moyenne et écart-type
notes.aggregateByKey((0L, 0.0, 0.0))(
  (acc, v) => (acc._1 + 1, acc._2 + v, acc._3 + v * v),
  (a, b)   => (a._1 + b._1, a._2 + b._2, a._3 + b._3)
)

Le point commun : l’accumulateur est borné en taille. C’est ce qui distingue ces agrégations d’un groupByKey, dont la valeur croît avec le nombre d’éléments et finit par saturer un exécuteur.

combineByKey : la forme générale

Les trois précédentes en sont des cas particuliers. Elle demande trois fonctions :

scala
def combineByKey[C](
  creerCombineur: V => C,               // première valeur rencontrée pour une clé
  fusionnerValeur: (C, V) => C,         // valeur suivante dans la même partition
  fusionnerCombineurs: (C, C) => C      // entre partitions
): RDD[(K, C)]

La même moyenne :

scala
val moyennes = notes
  .combineByKey(
    (note: Int) => (note, 1),                              // créer
    (acc: (Int, Int), note: Int) => (acc._1 + note, acc._2 + 1),  // fusionner une valeur
    (a: (Int, Int), b: (Int, Int)) => (a._1 + b._1, a._2 + b._2)  // fusionner deux accs
  )
  .mapValues { case (somme, compte) => somme.toDouble / compte }

La différence avec aggregateByKey est subtile mais utile : au lieu d’une valeur initiale fixe, on fournit une fonction qui construit l’accumulateur à partir de la première valeur rencontrée. C’est indispensable quand l’accumulateur ne peut pas avoir de valeur neutre naturelle — par exemple si vous accumulez dans une structure qui doit être initialisée avec une vraie donnée.

Dans 95 % des cas, aggregateByKey suffit et se lit mieux. combineByKey reste la porte de sortie quand elle ne suffit pas.

Et en DataFrame ? Beaucoup plus court

Puisque ces méthodes appartiennent à l’API RDD, la question se pose : en DataFrame, une moyenne par clé s’écrit ainsi :

scala
import org.apache.spark.sql.functions._

notes.toDF("prenom", "note")
  .groupBy("prenom")
  .agg(avg("note").as("moyenne"), count("*").as("nb"))

Une ligne, et Catalyst optimise tout — avec le même combine local, sans que vous ayez à l’exprimer. C’est l’illustration directe de ce qui est développé dans RDD ou DataFrame : la famille aggregateByKey reste indispensable pour comprendre le mécanisme et pour les cas sans équivalent SQL, mais en production, l’agrégation DataFrame gagne presque toujours.

Ce qu’il faut retenir

reduceByKey bloque dès que le résultat change de type — c’est la limite, et c’est pour la franchir que la famille existe. foldByKey ajoute un élément neutre. aggregateByKey autorise un accumulateur d’un type différent, et couvre à peu près tout : moyennes, min/max simultanés, ensembles distincts, statistiques. combineByKey est la forme générale, à garder pour les accumulateurs sans valeur neutre.

Le critère qui compte dans tous les cas : un accumulateur de taille bornée. C’est ce qui garantit que le shuffle transporte quelques octets par clé au lieu de toutes les valeurs — la différence exacte entre ces méthodes et un groupByKey qui finit par tomber en mémoire.

Ce sujet fait partie d’un cours complet

Développement et déploiement de solutions de données — les premiers modules sont en accès libre.

Voir le plan du cours

Continuer sur le même sujet